diff --git a/.gitignore b/.gitignore index 77f4b90c8..3c486ed48 100644 --- a/.gitignore +++ b/.gitignore @@ -1,46 +1,237 @@ node_modules/ dist/ - -# Go tool bundles built in place (go build in a module's cmd/-tools) are build output. -modules/pacman/pacman-tools -modules/slack/cmd/slack-tools/slack-tools -modules/jetbrains-toolbox/cmd/toolbox-tools/toolbox-tools -modules/messenger/cmd/messenger/messenger -modules/mesh-watcher/cmd/mesh-watcher/mesh-watcher -modules/claude-code/cmd/claude-code/claude-code -modules/dnsmasq/cmd/dnsmasq-tools/dnsmasq-tools -# ...and the same built from the module's root (go build ./cmd/), which names it after the module. -modules/messenger/messenger -modules/claude-code/claude-code -modules/mesh-watcher/mesh-watcher -modules/dnsmasq/dnsmasq -modules/mesh-issues/cmd/mesh-issues/mesh-issues -modules/mesh-issues/mesh-issues -modules/mounts/cmd/mounts/mounts -modules/mesh-knowledge/cmd/mesh-knowledge/mesh-knowledge -modules/mesh-knowledge/mesh-knowledge -modules/mesh-reads/mesh-reads -modules/mesh-reads/cmd/mesh-reads/mesh-reads -modules/mesh-files/mesh-files -modules/mesh-files/cmd/mesh-files/mesh-files - -# The provisioner client built in place (issue 671). -modules/mesh-provisioner-client/cmd/mesh-provisioner-client/mesh-provisioner-client -modules/brother/cmd/brother/brother -modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go -modules/mesh-config/mesh-config -modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image *.test -# binaries a local go build leaves in a module root +# Every program a local `go build` leaves, in its main package's directory and at its Go module's root, each anchored to +# its one path so it never hides source (issue 1577: twice in one milestone a module's program was committed). The +# checks/gitignored test refuses a main package whose program no ignore file names: add its line here. +/checks/agenthome/agenthome +/checks/config/config +/checks/deps/deps +/checks/environment/environment +/checks/imagebuild/imagebuild +/checks/imageplace/imageplace +/checks/locks/locks +/checks/mailstore/mailstore +/checks/noplacement/noplacement +/checks/owned/owned +/checks/particulars/particulars /checks/paths/paths +/checks/predefined/predefined +/checks/readable/readable +/checks/removedpackages/removedpackages +/checks/requiredmodules/requiredmodules +/checks/secrets/secrets +/checks/sshdir/sshdir +/checks/templates/templates +/checks/toolchains/toolchains +/checks/words/words +/modules/adwaita/adwaita-tools +/modules/adwaita/cmd/adwaita-tools/adwaita-tools +/modules/artifact-store-tools/artifact-store-tools +/modules/artifact-store-tools/cmd/artifact-store-tools/artifact-store-tools +/modules/asus-zephyrus-g14/cmd/zephyrus/zephyrus +/modules/asus-zephyrus-g14/zephyrus +/modules/avahi/avahi-tools +/modules/avahi/cmd/avahi-tools/avahi-tools +/modules/blueman/blueman-tools +/modules/blueman/cmd/blueman-tools/blueman-tools +/modules/bluetooth/bluetooth-tools +/modules/bluetooth/cmd/bluetooth-tools/bluetooth-tools +/modules/brother/brother +/modules/brother/cmd/brother/brother +/modules/claude-code/claude-code +/modules/claude-code/cmd/claude-code/claude-code +/modules/claude-licence-manager/claude-licence-manager +/modules/claude-licence-manager/cmd/claude-licence-manager/claude-licence-manager +/modules/clipmenu/clipmenu-tools +/modules/clipmenu/cmd/clipmenu-tools/clipmenu-tools +/modules/cups/cmd/cups-tools/cups-tools +/modules/cups/cups-tools +/modules/dbus/cmd/dbus-tools/dbus-tools +/modules/dbus/dbus-tools +/modules/desk-channel/cmd/desk-channel/desk-channel +/modules/desk-channel/desk-channel +/modules/disk-load/cmd/disk-load/disk-load +/modules/disk-load/disk-load +/modules/distribution/cmd/distribution-tools/distribution-tools +/modules/distribution/cmd/distribution/distribution +/modules/distribution/distribution +/modules/distribution/distribution-tools +/modules/dmenu/cmd/dmenu-tools/dmenu-tools +/modules/dmenu/dmenu-tools +/modules/dnsmasq/cmd/dnsmasq-tools/dnsmasq-tools +/modules/dnsmasq/dnsmasq-tools +/modules/docker-compose/cmd/docker-compose-tools/docker-compose-tools +/modules/docker-compose/docker-compose-tools +/modules/docker/cmd/docker-tools/docker-tools +/modules/docker/docker-tools +/modules/dunst/cmd/dunst-tools/dunst-tools +/modules/dunst/dunst-tools +/modules/fail2ban/cmd/fail2ban-tools/fail2ban-tools +/modules/fail2ban/fail2ban-tools +/modules/feh/cmd/feh-tools/feh-tools +/modules/feh/feh-tools +/modules/flatpak/cmd/flatpak-tools/flatpak-tools +/modules/flatpak/flatpak-tools +/modules/fonts/cmd/fonts-tools/fonts-tools +/modules/fonts/fonts-tools +/modules/forticlient/cmd/forticlient-tools/forticlient-tools +/modules/forticlient/forticlient-tools +/modules/gitea/agent-teams +/modules/gitea/agent-token +/modules/gitea/cmd/agent-teams/agent-teams +/modules/gitea/cmd/agent-token/agent-token +/modules/gitea/cmd/forge-account-provider/forge-account-provider +/modules/gitea/cmd/forge/forge +/modules/gitea/cmd/package-registry/package-registry +/modules/gitea/cmd/repositories/repositories +/modules/gitea/forge +/modules/gitea/forge-account-provider +/modules/gitea/package-registry +/modules/gitea/repositories +/modules/gnome-keyring/cmd/gnome-keyring-tools/gnome-keyring-tools +/modules/gnome-keyring/gnome-keyring-tools +/modules/hostname/cmd/hostname-tools/hostname-tools +/modules/hostname/hostname-tools +/modules/i3/cmd/i3-tools/i3-tools +/modules/i3/i3-tools +/modules/i3status-rust/cmd/i3status-rust-tools/i3status-rust-tools +/modules/i3status-rust/i3status-rust-tools +/modules/kernel/cmd/kernel-tools/kernel-tools +/modules/kernel/kernel-tools +/modules/keycloak/cmd/keycloak-provider/keycloak-provider +/modules/keycloak/keycloak-provider +/modules/lemurs/cmd/lemurs-tools/lemurs-tools +/modules/lemurs/lemurs-tools +/modules/localization/cmd/localization-tools/localization-tools +/modules/localization/localization-tools +/modules/logrotate/cmd/logrotate-tools/logrotate-tools +/modules/logrotate/logrotate-tools +/modules/mailu/automx +/modules/mailu/cmd/automx/automx +/modules/memory-pressure/cmd/memory-pressure/memory-pressure +/modules/memory-pressure/memory-pressure +/modules/mesh-build-go/buildstep/cmd/mesh-pack/mesh-pack +/modules/mesh-build-go/buildstep/cmd/seatgen/seatgen +/modules/mesh-build-go/buildstep/mesh-pack +/modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go /modules/mesh-build-go/mesh-build-go +/modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image +/modules/mesh-build-image/mesh-build-image +/modules/mesh-build-node/cmd/mesh-build-node/mesh-build-node +/modules/mesh-build-node/mesh-build-node +/modules/mesh-cli/cmd/mesh-cli/mesh-cli +/modules/mesh-cli/mesh-cli +/modules/mesh-conditions/cmd/mesh-conditions/mesh-conditions +/modules/mesh-conditions/mesh-conditions +/modules/mesh-config/cmd/mesh-config/mesh-config +/modules/mesh-config/mesh-config +/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/mesh-delivery-gate /modules/mesh-delivery-gate/mesh-delivery-gate -modules/mesh-config/cmd/mesh-config/mesh-config - -# Built by their modules' build step (issue 1577), never committed. -modules/forticlient/cmd/forticlient-tools/forticlient-tools -modules/networkmanager/uplink-tools -checks/words/words -modules/mesh-cli/mesh-cli -modules/mesh-cli/cmd/mesh-cli/mesh-cli +/modules/mesh-files/cmd/mesh-files/mesh-files +/modules/mesh-files/mesh-files +/modules/mesh-hand-acts/cmd/mesh-hand-acts/mesh-hand-acts +/modules/mesh-hand-acts/mesh-hand-acts +/modules/mesh-healers/cmd/mesh-healers/mesh-healers +/modules/mesh-healers/mesh-healers +/modules/mesh-issues/cmd/mesh-issues/mesh-issues +/modules/mesh-issues/mesh-issues +/modules/mesh-knowledge/cmd/mesh-knowledge/mesh-knowledge +/modules/mesh-knowledge/mesh-knowledge +/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/mesh-mail-notifier +/modules/mesh-mail-notifier/mesh-mail-notifier +/modules/mesh-mail-reader/cmd/mesh-mail-reader/mesh-mail-reader +/modules/mesh-mail-reader/mesh-mail-reader +/modules/mesh-mail-web/cmd/mesh-mail-web/mesh-mail-web +/modules/mesh-mail-web/mesh-mail-web +/modules/mesh-mail/cmd/mesh-mail/mesh-mail +/modules/mesh-mail/mesh-mail +/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/mesh-provisioner-client +/modules/mesh-provisioner-client/mesh-provisioner-client +/modules/mesh-provisioner/cmd/mesh-provisioner/mesh-provisioner +/modules/mesh-provisioner/mesh-provisioner +/modules/mesh-reads/cmd/mesh-reads/mesh-reads +/modules/mesh-reads/mesh-reads +/modules/mesh-self-check/cmd/mesh-self-check/mesh-self-check +/modules/mesh-self-check/mesh-self-check +/modules/mesh-vault/cmd/mesh-vault/mesh-vault +/modules/mesh-vault/mesh-vault +/modules/mesh-watcher/cmd/mesh-watcher/mesh-watcher +/modules/mesh-watcher/mesh-watcher +/modules/messenger/cmd/messenger/messenger +/modules/messenger/messenger +/modules/minio/cmd/object-store/object-store +/modules/minio/object-store +/modules/mounts/cmd/mounts/mounts +/modules/mounts/mounts +/modules/nats/cmd/mesh-nats-snapshot/mesh-nats-snapshot +/modules/nats/cmd/nats-tools/nats-tools +/modules/nats/mesh-nats-snapshot +/modules/nats/nats-tools +/modules/netcheck/cmd/netcheck/netcheck +/modules/netcheck/netcheck +/modules/networkmanager/cmd/uplink-tools/uplink-tools +/modules/networkmanager/uplink-tools +/modules/nextcloud-client/cmd/nextcloud-client-tools/nextcloud-client-tools +/modules/nextcloud-client/nextcloud-client-tools +/modules/nfs-server/cmd/nfs-server/nfs-server +/modules/nfs-server/nfs-server +/modules/nm-applet/cmd/nm-applet-tools/nm-applet-tools +/modules/nm-applet/nm-applet-tools +/modules/only-office/cmd/only-office/only-office +/modules/only-office/only-office +/modules/openrazer/cmd/openrazer-tools/openrazer-tools +/modules/openrazer/openrazer-tools +/modules/pacman/cmd/pacman-tools/pacman-tools +/modules/pacman/pacman-tools +/modules/picom/cmd/picom-tools/picom-tools +/modules/picom/picom-tools +/modules/polychromatic/cmd/polychromatic-tools/polychromatic-tools +/modules/polychromatic/polychromatic-tools +/modules/postgres/cmd/postgres-provider/postgres-provider +/modules/postgres/postgres-provider +/modules/power/cmd/power/power +/modules/power/power +/modules/records/cmd/records/records +/modules/records/records +/modules/restic/cmd/restic-backups/restic-backups +/modules/restic/restic-backups +/modules/rofi/cmd/rofi-tools/rofi-tools +/modules/rofi/rofi-tools +/modules/route-adapter/cmd/route-provider/route-provider +/modules/route-adapter/route-provider +/modules/screen-lock/cmd/screen-lock-tools/screen-lock-tools +/modules/screen-lock/screen-lock-tools +/modules/sensors/cmd/sensors/sensors +/modules/sensors/sensors +/modules/slack/cmd/slack-tools/slack-tools +/modules/slack/slack-tools +/modules/snapd/cmd/snapd-tools/snapd-tools +/modules/snapd/snapd-tools +/modules/ssh-client/cmd/ssh-client-tools/ssh-client-tools +/modules/ssh-client/ssh-client-tools +/modules/sudo/cmd/sudo-tools/sudo-tools +/modules/sudo/sudo-tools +/modules/systemd-networkd/cmd/uplink-tools/uplink-tools +/modules/systemd-networkd/uplink-tools +/modules/systemd-resolved/cmd/resolver-tools/resolver-tools +/modules/systemd-resolved/resolver-tools +/modules/systemd/cmd/systemd-tools/systemd-tools +/modules/systemd/systemd-tools +/modules/telegram/cmd/telegram/telegram +/modules/telegram/telegram +/modules/time-sync/cmd/time-sync-tools/time-sync-tools +/modules/time-sync/time-sync-tools +/modules/triggerhappy/cmd/triggerhappy-tools/triggerhappy-tools +/modules/triggerhappy/triggerhappy-tools +/modules/xclip/cmd/xclip-tools/xclip-tools +/modules/xclip/xclip-tools +/modules/xdg/cmd/xdg-tools/xdg-tools +/modules/xdg/xdg-tools +/modules/xorg/cmd/xorg/xorg +/modules/xorg/xorg +/modules/xterm/cmd/xterm-tools/xterm-tools +/modules/xterm/xterm-tools +/modules/zfs/cmd/zfs-tools/zfs-tools +/modules/zfs/zfs-tools diff --git a/checks/config/go.mod b/checks/config/go.mod index 10bdf5795..d8c1d6f40 100644 --- a/checks/config/go.mod +++ b/checks/config/go.mod @@ -2,6 +2,6 @@ module git.novox.be/novox/mesh-catalog/checks/config go 1.22 -require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0 +require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0-00010101000000-000000000000 replace git.novox.be/novox/mesh-catalog/checks/trunk => ../trunk diff --git a/checks/controllerdeps/controllerdeps_test.go b/checks/controllerdeps/controllerdeps_test.go index 0a05e0ae0..df5b26732 100644 --- a/checks/controllerdeps/controllerdeps_test.go +++ b/checks/controllerdeps/controllerdeps_test.go @@ -39,7 +39,7 @@ func TestRefusalsJudgeATree(t *testing.T) { {"a bucket in a test is its data", map[string]string{"modules/e/cmd/e/main_test.go": "package main\n// mesh-controller_lease\n"}, ""}, {"a fixture copied from elsewhere", map[string]string{"modules/e/cmd/e/testdata/x.md": "mesh-controller_rollouts\n"}, ""}, {"an owner and prose pass", map[string]string{"modules/f/module.json": `{"module":"f","config":{"owners":{"default":["mesh-controller","mesh-controller modules/route-proxy"]}},"why":"the mesh-controller module is gone"}`}, ""}, - {"the seat's successors pass", map[string]string{"modules/g/module.json": `{"module":"g","reads":["mesh-placement.placement"],"invokes":["seat:mesh-hand-acts.hand-act"]}`}, ""}, + {"the seat's successors pass", map[string]string{"modules/g/module.json": `{"module":"g","reads":["mesh-module-assigner.assignments"],"invokes":["seat:mesh-hand-acts.hand-act"]}`}, ""}, } { t.Run(row.name, func(t *testing.T) { root := t.TempDir() diff --git a/checks/credentialform/go.mod b/checks/credentialform/go.mod index 1fbbb3796..69618cea6 100644 --- a/checks/credentialform/go.mod +++ b/checks/credentialform/go.mod @@ -2,4 +2,4 @@ module checks/credentialform go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 diff --git a/checks/credentialform/go.sum b/checks/credentialform/go.sum index 2effc70b8..e69de29bb 100644 --- a/checks/credentialform/go.sum +++ b/checks/credentialform/go.sum @@ -1,2 +0,0 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= diff --git a/checks/environment/go.mod b/checks/environment/go.mod new file mode 100644 index 000000000..1ef966744 --- /dev/null +++ b/checks/environment/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-catalog/checks/environment + +go 1.22 diff --git a/checks/environment/main.go b/checks/environment/main.go new file mode 100644 index 000000000..e8cd65a69 --- /dev/null +++ b/checks/environment/main.go @@ -0,0 +1,252 @@ +// Command environment refuses a module's code reading a MESH_* word of its environment that nothing gives it: not its +// manifest, and not the node-engine or the tool runner for every process (the lead's ruling on A3n, issue 1966). +// +// A MESH_* word is the mesh's contract with a module's process: the manifest sets it (an `env` of a build artifact or a +// resource, an `environment`'s `variables`, a `NAME=` line of a file the module ships for an env-file), or the mesh gives +// it to every process (Given). A word read and set by nothing is a contract nobody keeps: the program runs on its +// default or on nothing, and says so to nobody (MESH_REGISTRY_REACH answered every published reference without its +// registry; MESH_CONFIG_CALLERS held every window shut). Found here, it is refused: the code reads what the mesh gives +// (a manifest word, a config value through its tools, a seat's name), or the manifest sets it. +// +// A read is `os.Getenv` or `os.LookupEnv` of a literal or of a constant of the same directory (Go), and `env.MESH_*` or +// `env["MESH_*"]` (TypeScript, `process.env` included); tests, test directories, node_modules and dist are not read. The +// modules still reading such a word are listed in the file `unset` beside this check, ` ` a line, until +// each is fixed: a line no longer found is refused, so the list only shrinks. +// +// go run . +// +// Exits 1 on a finding. +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// Given is every MESH_* word the mesh gives a module's processes whatever their manifest says, and who gives it. +var Given = map[string]string{ + // The tool runner, to every bundle and to a module's own runtime (mesh-tools node-tools runtime.BundleEnv). + "MESH_MODULE": "the tool runner", + "MESH_SERVED_MODULE": "the tool runner", + "MESH_NODE": "the tool runner", + // The node-engine, to the tool runner it runs as the operator's account; its bundles inherit them (mesh-host + // compose/resolution runtime.go). + "MESH_OPERATOR_ACCOUNT": "the node-engine", + "MESH_OPERATOR_HOME": "the node-engine", + // The node-engine, to every module that yields to the node's builds (ADR 0553 §3; catalogue.BuildCPUShareWord). + "MESH_BUILD_CPU_SHARE": "the node-engine", +} + +const listFile = "unset" + +var ( + goRead = regexp.MustCompile(`os\.(?:Getenv|LookupEnv)\(\s*(?:"(MESH_[A-Z0-9_]+)"|([A-Za-z_][A-Za-z0-9_]*))\s*\)`) + goConst = regexp.MustCompile(`\b([A-Za-z_][A-Za-z0-9_]*)\s*(?:string\s*)?=\s*"(MESH_[A-Z0-9_]+)"`) + tsRead = regexp.MustCompile(`\benv(?:\.|\[\s*["'])(MESH_[A-Z0-9_]+)`) + envLine = regexp.MustCompile(`(?m)^\s*(?:export\s+)?(MESH_[A-Z0-9_]+)=`) +) + +// skipped are the directories whose code no process of the module runs. +var skipped = map[string]bool{"node_modules": true, "dist": true, "test": true, "testdata": true, ".git": true} + +// Check answers, per module, each MESH_* word its code reads that its manifest does not set and the mesh does not +// give, with the files reading it. +func Check(root string) (map[string]map[string][]string, error) { + manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) + if err != nil { + return nil, err + } + sort.Strings(manifests) + out := map[string]map[string][]string{} + for _, path := range manifests { + dir := filepath.Dir(path) + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var manifest any + if err := json.Unmarshal(raw, &manifest); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + set := map[string]bool{} + setBy(manifest, set) + reads := map[string]map[string]bool{} + consts := map[string]map[string]string{} // directory -> constant -> word + var goFiles []string + err = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if p != dir && skipped[d.Name()] { + return filepath.SkipDir + } + return nil + } + name := d.Name() + text, err := os.ReadFile(p) + if err != nil { + return err + } + rel, _ := filepath.Rel(dir, p) + rel = filepath.ToSlash(rel) + switch { + case strings.HasSuffix(name, "_test.go"), strings.HasSuffix(name, ".test.ts"), strings.HasSuffix(name, ".d.ts"): + case strings.HasSuffix(name, ".go"): + goFiles = append(goFiles, p) + c := consts[filepath.Dir(p)] + if c == nil { + c = map[string]string{} + consts[filepath.Dir(p)] = c + } + for _, m := range goConst.FindAllStringSubmatch(string(text), -1) { + c[m[1]] = m[2] + } + case strings.HasSuffix(name, ".ts"): + for _, m := range tsRead.FindAllStringSubmatch(string(text), -1) { + read(reads, m[1], rel) + } + case name != "module.json": + // A file the module ships: a NAME= line is a word an env-file gives. + for _, m := range envLine.FindAllStringSubmatch(string(text), -1) { + set[m[1]] = true + } + } + return nil + }) + if err != nil { + return nil, err + } + for _, p := range goFiles { + text, _ := os.ReadFile(p) + rel, _ := filepath.Rel(dir, p) + for _, m := range goRead.FindAllStringSubmatch(string(text), -1) { + word := m[1] + if word == "" { + word = consts[filepath.Dir(p)][m[2]] + } + if word != "" { + read(reads, word, filepath.ToSlash(rel)) + } + } + } + module := filepath.Base(dir) + for word, files := range reads { + if set[word] || Given[word] != "" { + continue + } + if out[module] == nil { + out[module] = map[string][]string{} + } + for f := range files { + out[module][word] = append(out[module][word], f) + } + sort.Strings(out[module][word]) + } + } + return out, nil +} + +func read(reads map[string]map[string]bool, word, file string) { + if reads[word] == nil { + reads[word] = map[string]bool{} + } + reads[word][file] = true +} + +// setBy collects the words a manifest sets: every key of an `env` object and of an `environment`'s `variables`. +func setBy(v any, set map[string]bool) { + switch v := v.(type) { + case map[string]any: + for k, e := range v { + if m, ok := e.(map[string]any); ok && (k == "env" || k == "variables") { + for word := range m { + set[word] = true + } + } + setBy(e, set) + } + case []any: + for _, e := range v { + setBy(e, set) + } + } +} + +// Listed is the list's ` ` lines; a line starting with # and a blank line are not entries. +func Listed(path string) (map[string]bool, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + out := map[string]bool{} + s := bufio.NewScanner(f) + for s.Scan() { + line := strings.Join(strings.Fields(s.Text()), " ") + if line == "" || strings.HasPrefix(line, "#") { + continue + } + out[line] = true + } + return out, s.Err() +} + +// Judge answers the reads not on the list (refused) and the list's lines no read stands for (stale). +func Judge(found map[string]map[string][]string, listed map[string]bool) (refused, stale []string) { + seen := map[string]bool{} + for module, words := range found { + for word := range words { + line := module + " " + word + seen[line] = true + if !listed[line] { + refused = append(refused, line) + } + } + } + for line := range listed { + if !seen[line] { + stale = append(stale, line) + } + } + sort.Strings(refused) + sort.Strings(stale) + return refused, stale +} + +func main() { + root := "." + if len(os.Args) > 1 { + root = os.Args[1] + } + found, err := Check(root) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + listed, err := Listed(listFile) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + refused, stale := Judge(found, listed) + for _, line := range refused { + module, word, _ := strings.Cut(line, " ") + fmt.Printf("%s reads %s (%s), which its manifest does not set and the mesh gives no process: set it in the "+ + "manifest, or read what the mesh gives\n", module, word, strings.Join(found[module][word], ", ")) + } + for _, line := range stale { + fmt.Printf("checks/environment/%s names %q, which no code reads unset any more: take it off the list\n", listFile, line) + } + if len(refused) > 0 || len(stale) > 0 { + os.Exit(1) + } + fmt.Printf("environment: every MESH_* word a module reads is set by its manifest or given by the mesh (%d still listed)\n", len(listed)) +} diff --git a/checks/environment/main_test.go b/checks/environment/main_test.go new file mode 100644 index 000000000..f79842cd1 --- /dev/null +++ b/checks/environment/main_test.go @@ -0,0 +1,88 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +// firstCount is how many lines the list held when it was first written: it only shrinks. +const firstCount = 63 + +func module(t *testing.T, root, name, manifest string, files map[string]string) { + t.Helper() + dir := filepath.Join(root, "modules", name) + for path, text := range map[string]string{"module.json": manifest} { + files[path] = text + } + for path, text := range files { + full := filepath.Join(dir, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(text), 0o644); err != nil { + t.Fatal(err) + } + } +} + +// A word read and set by nothing is found, in Go (a literal, a constant of the same directory) and in TypeScript; one +// the manifest sets (an env, an environment's variables, an env-file line it ships) or the mesh gives every process is +// not; tests, node_modules and dist are not read. +func TestAWordReadThatNothingSetsIsFound(t *testing.T) { + root := t.TempDir() + module(t, root, "reader", `{"module":"reader","build":{"artifacts":[{"name":"tools","env":{"MESH_SET":"x"}}]},`+ + `"resources":[{"id":"p","type":"process","environment":{"variables":{"MESH_VARIABLE":"y"}}}]}`, map[string]string{ + "cmd/a/main.go": `package main; const reach = "MESH_REACH"; var _ = os.Getenv(reach); var _ = os.Getenv("MESH_SET")`, + "cmd/a/other.go": `package main; var _, _ = os.LookupEnv("MESH_LITERAL"); var _ = os.Getenv("MESH_MODULE")`, + "cmd/a/write.go": `package main; var _ = []string{"MESH_WRITTEN_FOR_A_CHILD=1"}`, + "cmd/a/a_test.go": `package main; var _ = os.Getenv("MESH_IN_A_TEST")`, + "client.ts": `const a = process.env.MESH_TS ?? "d"; const b = env["MESH_BRACKET"]; const c = env.MESH_VARIABLE;`, + "files/env": "MESH_FROM_FILE=${config:x}\n", + "index.ts": `const f = env.MESH_FROM_FILE;`, + "test/x.test.ts": `env.MESH_IN_A_TEST_DIR`, + "node_modules/x.ts": `env.MESH_VENDORED`, + }) + module(t, root, "quiet", `{"module":"quiet"}`, map[string]string{"main.go": `package main; var _ = os.Getenv("MESH_NODE")`}) + got, err := Check(root) + if err != nil { + t.Fatal(err) + } + want := map[string]map[string][]string{"reader": { + "MESH_REACH": {"cmd/a/main.go"}, + "MESH_LITERAL": {"cmd/a/other.go"}, + "MESH_TS": {"client.ts"}, + "MESH_BRACKET": {"client.ts"}, + }} + if !reflect.DeepEqual(got, want) { + t.Fatalf("found %v, want %v", got, want) + } + refused, stale := Judge(got, map[string]bool{"reader MESH_REACH": true, "reader MESH_GONE": true}) + if !reflect.DeepEqual(refused, []string{"reader MESH_BRACKET", "reader MESH_LITERAL", "reader MESH_TS"}) || + !reflect.DeepEqual(stale, []string{"reader MESH_GONE"}) { + t.Errorf("refused %v, stale %v", refused, stale) + } +} + +// The catalogue itself: every word a module reads is set or given, but the list's, and the list only shrinks. +func TestTheCatalogueReadsOnlyWordsTheMeshSets(t *testing.T) { + found, err := Check(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + listed, err := Listed(listFile) + if err != nil { + t.Fatal(err) + } + refused, stale := Judge(found, listed) + for _, line := range refused { + t.Errorf("%s: read, set by nothing, and not on checks/environment/unset", line) + } + for _, line := range stale { + t.Errorf("checks/environment/unset names %q, which no code reads unset any more", line) + } + if len(listed) > firstCount { + t.Errorf("checks/environment/unset holds %d lines, more than its first %d: it only shrinks", len(listed), firstCount) + } +} diff --git a/checks/environment/reads-outside b/checks/environment/reads-outside new file mode 100644 index 000000000..e1553863c --- /dev/null +++ b/checks/environment/reads-outside @@ -0,0 +1,5 @@ +# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole; +# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept +# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing. +# Check("../.."): every manifest and the code and files of its module. +modules diff --git a/checks/environment/unset b/checks/environment/unset new file mode 100644 index 000000000..5840f12be --- /dev/null +++ b/checks/environment/unset @@ -0,0 +1,67 @@ +# The MESH_* words a module's code still reads that its manifest does not set and the mesh gives no process, +# ` ` a line, as checks/environment first found them (issue 1966). Each is a contract nobody keeps: fix +# it (the manifest sets it, or the code reads what the mesh gives) and take its line off. A line no code reads unset any +# more is refused, so this list only shrinks; a new read set by nothing is refused, never added here. +baserow MESH_BASEROW_EMAIL +baserow MESH_BASEROW_HOST +baserow MESH_BASEROW_PASSWORD +claude-code MESH_AGENT_ACCOUNT +claude-code MESH_AGENT_HOME +claude-licence-manager MESH_ANTHROPIC_TOKEN_ENDPOINT +claude-licence-manager MESH_ANTHROPIC_USAGE_ENDPOINT +confluence MESH_CONFLUENCE_EMAIL +confluence MESH_CONFLUENCE_TOKEN +confluence MESH_CONFLUENCE_URL +disk-load MESH_DISK_MEASURE_EVERY +gitlab MESH_GITLAB_TOKEN +gitlab MESH_GITLAB_URL +grafana MESH_GRAFANA_PASSWORD +grafana MESH_GRAFANA_TOKEN +grafana MESH_GRAFANA_USER +home-assistant MESH_HOMEASSISTANT_TOKEN +home-assistant MESH_HOMEASSISTANT_WAIT_SECONDS +home-assistant MESH_HOMEASSISTANT_WATCH +icecast MESH_ICECAST_ADMIN_PASSWORD +icecast MESH_ICECAST_ADMIN_USER +influxdb MESH_INFLUXDB_ORG +influxdb MESH_INFLUXDB_TOKEN +jira MESH_JIRA_EMAIL +jira MESH_JIRA_TOKEN +jira MESH_JIRA_URL +letta MESH_LETTA_PASSWORD +mailu MESH_MAILU_API_KEY +mailu MESH_MAILU_DOMAIN +mesh-cli MESH_CONSOLE +mesh-cli MESH_VERB +mesh-files MESH_OWNED_PATHS +mesh-files MESH_SECRET_PLACES +mesh-reads MESH_SECRET_PLACES +minio MESH_MINIO_ROOT_PASSWORD +mongodb MESH_MONGODB_AUTHSOURCE +mongodb MESH_MONGODB_HOST +mongodb MESH_MONGODB_PASSWORD +mongodb MESH_MONGODB_PORT +mongodb MESH_MONGODB_USER +mosquitto MESH_MQTT_ADMIN_USER +mosquitto MESH_MQTT_BROKER_GID +mosquitto MESH_MQTT_BROKER_UID +mosquitto MESH_MQTT_HOST +mosquitto MESH_MQTT_PASSWORD +mosquitto MESH_MQTT_PORT +mssql MESH_MSSQL_HOST +mssql MESH_MSSQL_PASSWORD +mssql MESH_MSSQL_PORT +mssql MESH_MSSQL_READER_PASSWORD +mssql MESH_MSSQL_USER +nextcloud MESH_NEXTCLOUD_ADMIN_PASSWORD +nextcloud MESH_NEXTCLOUD_CONTAINER +nodered MESH_NODERED_TOKEN +nodered MESH_NODERED_WAIT_SECONDS +redis MESH_REDIS_HOST +redis MESH_REDIS_PASSWORD +redis MESH_REDIS_PORT +redis MESH_REDIS_USERNAME +restic MESH_BACKUP_HOUR +unifi MESH_UNIFI_PASSWORD +unifi MESH_UNIFI_SITE +unifi MESH_UNIFI_USERNAME diff --git a/checks/gitignored/gitignored.go b/checks/gitignored/gitignored.go new file mode 100644 index 000000000..4ede4c993 --- /dev/null +++ b/checks/gitignored/gitignored.go @@ -0,0 +1,292 @@ +// Package gitignored refuses an ignore pattern that hides a module's source (catalogue review F1, and its kind): the +// bare pattern `mesh-build-node` in a module's .gitignore, meant for the binary `go build` leaves, also matched the +// directory cmd/mesh-build-node, so git ignored every new file there and publish.go was never committed. A path under +// a module's cmd/ or internal/ is source: no .gitignore, the module's or the catalogue's, may ignore one, tracked or +// new. A binary is ignored by an anchored pattern (`/mesh-build-node`, `/cmd/mesh-build-node/mesh-build-node`). +package gitignored + +import ( + "bytes" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +// Probe is the file name asked of each source directory: a Go file a change might add there. +const Probe = "added_by_a_change.go" + +// Hidden is one source path an ignore pattern hides, and the pattern (its file, line and text, as git names it). +type Hidden struct { + Path, Pattern string +} + +// Sources are the source directories of root's modules, relative to root: every directory under modules/*/cmd and +// modules/*/internal (node_modules left out). +func Sources(root string) ([]string, error) { + mods, err := filepath.Glob(filepath.Join(root, "modules", "*")) + if err != nil { + return nil, err + } + var out []string + for _, m := range mods { + for _, top := range []string{"cmd", "internal"} { + base := filepath.Join(m, top) + if st, err := os.Stat(base); err != nil || !st.IsDir() { + continue + } + err := filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + return nil + } + if d.Name() == "node_modules" { + return filepath.SkipDir + } + rel, _ := filepath.Rel(root, p) + out = append(out, filepath.ToSlash(rel)) + return nil + }) + if err != nil { + return nil, err + } + } + } + sort.Strings(out) + return out, nil +} + +// isSource is whether a file is source a module's build reads: Go and TypeScript files and a Go module's own files. +// What a build or a test script leaves beside them (a binary, compiled JavaScript) may be ignored. +func isSource(name string) bool { + switch filepath.Ext(name) { + case ".go", ".ts", ".tsx": + return true + } + return name == "go.mod" || name == "go.sum" +} + +// Find asks git which of the source paths root's ignore files hide: each source directory, a Go file added in it, and +// every source file there (isSource). It reads the .gitignore files of root's tree through a repository of its own, so it judges a git +// checkout and a plain folder alike, and no one's global ignores count. +func Find(root string, dirs []string) ([]Hidden, error) { + gitDir, err := os.MkdirTemp("", "gitignored-") + if err != nil { + return nil, err + } + defer os.RemoveAll(gitDir) + if out, err := exec.Command("git", "init", "-q", "--bare", gitDir).CombinedOutput(); err != nil { + return nil, fmt.Errorf("git init: %v: %s", err, out) + } + var ask bytes.Buffer + for _, d := range dirs { + ask.WriteString(d + "/\x00" + d + "/" + Probe + "\x00") + entries, err := os.ReadDir(filepath.Join(root, d)) + if err != nil { + return nil, err + } + for _, e := range entries { + if !e.IsDir() && isSource(e.Name()) { + ask.WriteString(d + "/" + e.Name() + "\x00") + } + } + } + abs, err := filepath.Abs(root) + if err != nil { + return nil, err + } + cmd := exec.Command("git", "--git-dir="+gitDir, "--work-tree="+abs, "-c", "core.excludesFile=/dev/null", + "check-ignore", "--no-index", "-v", "-z", "--stdin") + cmd.Dir = abs + cmd.Stdin = &ask + out, err := cmd.Output() + if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 { + return nil, nil // nothing is ignored + } + if err != nil { + return nil, fmt.Errorf("git check-ignore: %w", err) + } + // -v -z answers source, line, pattern and path, each ended by NUL. + f := strings.Split(strings.TrimRight(string(out), "\x00"), "\x00") + var hidden []Hidden + for i := 0; i+3 < len(f); i += 4 { + if f[i+2] == "" || strings.HasPrefix(f[i+2], "!") { + continue // a negation keeps the path + } + hidden = append(hidden, Hidden{Path: f[i+3], Pattern: fmt.Sprintf("%s:%s: %s", f[i], f[i+1], f[i+2])}) + } + return hidden, nil +} + +// Programs are the paths `go build` writes a compiled program to, for every main package of every Go module under +// root (issue 1577, met twice in one milestone: a module's program committed with `git add -A`): `go build` in the +// package's directory writes it there, and `go build ./` from the module's root writes it at the root, each named +// after the package's import path. Relative to root, sorted; directories named node_modules, testdata or vendor, and +// .git, are left out, and so is a path that is a directory of the tree. +func Programs(root string) ([]string, error) { + var mods []string + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() && skipped(d.Name()) && p != root { + return filepath.SkipDir + } + if !d.IsDir() && d.Name() == "go.mod" { + mods = append(mods, filepath.Dir(p)) + } + return nil + }) + if err != nil { + return nil, err + } + seen := map[string]bool{} + for _, m := range mods { + modPath, err := modulePath(filepath.Join(m, "go.mod")) + if err != nil { + return nil, err + } + err = filepath.WalkDir(m, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + return nil + } + if p != m { + if skipped(d.Name()) { + return filepath.SkipDir + } + if _, err := os.Stat(filepath.Join(p, "go.mod")); err == nil { + return filepath.SkipDir // a module of its own, walked on its own + } + } + isMain, err := mainPackage(p) + if err != nil || !isMain { + return err + } + name := filepath.Base(p) + if p == m { + name = pathBase(modPath) + } + for _, dir := range []string{p, m} { + out := filepath.Join(dir, name) + if st, err := os.Stat(out); err == nil && st.IsDir() { + continue // go build refuses to write over a directory, so no program lands there + } + rel, _ := filepath.Rel(root, out) + seen[filepath.ToSlash(rel)] = true + } + return nil + }) + if err != nil { + return nil, err + } + } + out := make([]string, 0, len(seen)) + for p := range seen { + out = append(out, p) + } + sort.Strings(out) + return out, nil +} + +// Unignored names each of programs (paths from root) that root's ignore files do not ignore: a program a local build +// leaves there that `git add -A` would commit. +func Unignored(root string, programs []string) ([]string, error) { + gitDir, err := os.MkdirTemp("", "gitignored-") + if err != nil { + return nil, err + } + defer os.RemoveAll(gitDir) + if out, err := exec.Command("git", "init", "-q", "--bare", gitDir).CombinedOutput(); err != nil { + return nil, fmt.Errorf("git init: %v: %s", err, out) + } + abs, err := filepath.Abs(root) + if err != nil { + return nil, err + } + cmd := exec.Command("git", "--git-dir="+gitDir, "--work-tree="+abs, "-c", "core.excludesFile=/dev/null", + "check-ignore", "--no-index", "-v", "-n", "-z", "--stdin") + cmd.Dir = abs + cmd.Stdin = strings.NewReader(strings.Join(programs, "\x00") + "\x00") + out, err := cmd.Output() + if ee, ok := err.(*exec.ExitError); !(err == nil || ok && ee.ExitCode() == 1) { + return nil, fmt.Errorf("git check-ignore: %w", err) + } + // -v -n -z answers source, line, pattern and path for every path asked; the source is empty for one no pattern + // matches, and a pattern starting with ! keeps the path. + f := strings.Split(strings.TrimRight(string(out), "\x00"), "\x00") + var open []string + for i := 0; i+3 < len(f); i += 4 { + if f[i+2] == "" || strings.HasPrefix(f[i+2], "!") { + open = append(open, f[i+3]) + } + } + return open, nil +} + +func skipped(name string) bool { + switch name { + case ".git", "node_modules", "testdata", "vendor": + return true + } + return false +} + +// modulePath reads the module line of a go.mod. +func modulePath(goMod string) (string, error) { + b, err := os.ReadFile(goMod) + if err != nil { + return "", err + } + for _, l := range strings.Split(string(b), "\n") { + if f := strings.Fields(l); len(f) >= 2 && f[0] == "module" { + return strings.Trim(f[1], `"`), nil + } + } + return "", fmt.Errorf("%s names no module", goMod) +} + +// pathBase is the name `go build` gives a program at a module's root: its path's last element, a major version +// suffix (/v2) left out. +func pathBase(modPath string) string { + parts := strings.Split(modPath, "/") + last := parts[len(parts)-1] + if len(parts) > 1 && len(last) > 1 && last[0] == 'v' && strings.Trim(last[1:], "0123456789") == "" { + last = parts[len(parts)-2] + } + return last +} + +// mainPackage is whether dir holds a Go file, not a test, of package main. +func mainPackage(dir string) (bool, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return false, err + } + for _, e := range entries { + n := e.Name() + if e.IsDir() || filepath.Ext(n) != ".go" || strings.HasSuffix(n, "_test.go") { + continue + } + b, err := os.ReadFile(filepath.Join(dir, n)) + if err != nil { + return false, err + } + for _, l := range strings.Split(string(b), "\n") { + if f := strings.Fields(l); len(f) >= 2 && f[0] == "package" { + if f[1] == "main" { + return true, nil + } + break + } + } + } + return false, nil +} diff --git a/checks/gitignored/gitignored_test.go b/checks/gitignored/gitignored_test.go new file mode 100644 index 000000000..ff1f40d05 --- /dev/null +++ b/checks/gitignored/gitignored_test.go @@ -0,0 +1,158 @@ +package gitignored + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// The kind of catalogue review F1: a bare pattern hides the command directory it shares a name with; an anchored one +// hides the binary alone; a glob that ignores source is refused too; a negation keeps a path. +func TestFindNamesEachPatternThatHidesSource(t *testing.T) { + for name, c := range map[string]struct { + ignore string + hidden []string + }{ + "a bare name hides cmd/": {"nfs-server\n", []string{"modules/m/cmd/nfs-server/", "modules/m/cmd/nfs-server/" + Probe, "modules/m/cmd/nfs-server/main.go"}}, + "anchored to the binary": {"/nfs-server\n/cmd/nfs-server/nfs-server\n", nil}, + "a glob ignoring Go source": {"*.go\n!main.go\n", []string{"modules/m/cmd/" + Probe, "modules/m/cmd/nfs-server/" + Probe, + "modules/m/internal/" + Probe, "modules/m/internal/x/" + Probe, "modules/m/internal/x/x.go"}}, + "a pattern over internal/": {"internal/\n", []string{"modules/m/internal/", "modules/m/internal/" + Probe, "modules/m/internal/x/", + "modules/m/internal/x/" + Probe, "modules/m/internal/x/x.go"}}, + "nothing but generated JavaScript": {"*.js\n", nil}, + } { + root := t.TempDir() + for f, b := range map[string]string{ + "modules/m/.gitignore": c.ignore, + "modules/m/cmd/nfs-server/main.go": "package main\n", + "modules/m/cmd/nfs-server/run.js": "", + "modules/m/internal/x/x.go": "package x\n", + "modules/m/node_modules/y/index.go": "", + "modules/other/cmd/other/other.go": "package main\n", + } { + p := filepath.Join(root, f) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(b), 0o644); err != nil { + t.Fatal(err) + } + } + dirs, err := Sources(root) + if err != nil { + t.Fatal(err) + } + got, err := Find(root, dirs) + if err != nil { + t.Fatal(err) + } + var paths []string + for _, h := range got { + paths = append(paths, h.Path) + if !strings.HasPrefix(h.Pattern, "modules/m/.gitignore:") { + t.Errorf("%s: %s is named hidden by %q, want the module's .gitignore", name, h.Path, h.Pattern) + } + } + if strings.Join(paths, ",") != strings.Join(c.hidden, ",") { + t.Errorf("%s: hidden %v, want %v", name, paths, c.hidden) + } + } +} + +// The catalogue's own tree: no ignore pattern hides a path under a module's cmd/ or internal/. +func TestNoIgnorePatternHidesAModulesSource(t *testing.T) { + root := filepath.Join("..", "..") + dirs, err := Sources(root) + if err != nil { + t.Fatal(err) + } + if len(dirs) == 0 { + t.Fatal("no module source directory found: the check reads nothing") + } + hidden, err := Find(root, dirs) + if err != nil { + t.Fatal(err) + } + for _, h := range hidden { + t.Errorf("%s is hidden by %s: a module's source is never ignored; anchor the pattern to the binary (/, /cmd//)", h.Path, h.Pattern) + } +} + +// The kind of issue 1577, met twice in one milestone: a module's program `go build` left in its folder was committed. +// Every program a main package builds, in its own directory and at its module's root, is found, and one no ignore file +// names is open; a directory of the same name is no program's place. +func TestEveryProgramABuildLeavesIsIgnored(t *testing.T) { + files := map[string]string{ + "modules/m/go.mod": "module example.com/m/v2\n", + "modules/m/main.go": "// a program\npackage main\n", + "modules/m/cmd/m-tools/main.go": "package main\n", + "modules/m/cmd/m-tools/main_test.go": "package main\n", + "modules/m/internal/x/x.go": "package x\n", + "modules/m/internal/x/x_test.go": "package main\n", + "modules/m/node_modules/y/y.go": "package main\n", + "modules/m/tool/go.mod": "module tool\n", + "modules/m/tool/cmd/tool/main.go": "package main\n", + "modules/m/tool/tool/source.go": "package tool\n", + "checks/c/go.mod": "module example.com/checks/c\n", + "checks/c/main.go": "package main\n", + } + for name, c := range map[string]struct { + ignore string + open []string + }{ + "nothing ignored": {"", []string{"checks/c/c", "modules/m/cmd/m-tools/m-tools", "modules/m/m", "modules/m/m-tools", + "modules/m/tool/cmd/tool/tool"}}, + "each anchored": {"/checks/c/c\n/modules/m/cmd/m-tools/m-tools\n/modules/m/m\n/modules/m/m-tools\n/modules/m/tool/cmd/tool/tool\n", nil}, + "one kept by a negation": {"/checks/c/c\n/modules/m/*\n!/modules/m/m\n/modules/m/cmd/m-tools/m-tools\n/modules/m/tool/cmd/tool/tool\n", + []string{"modules/m/m"}}, + } { + root := t.TempDir() + for f, b := range files { + p := filepath.Join(root, f) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(b), 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte(c.ignore), 0o644); err != nil { + t.Fatal(err) + } + programs, err := Programs(root) + if err != nil { + t.Fatal(err) + } + want := "checks/c/c,modules/m/cmd/m-tools/m-tools,modules/m/m,modules/m/m-tools,modules/m/tool/cmd/tool/tool" + if got := strings.Join(programs, ","); got != want { + t.Fatalf("%s: programs %s, want %s", name, got, want) + } + open, err := Unignored(root, programs) + if err != nil { + t.Fatal(err) + } + if strings.Join(open, ",") != strings.Join(c.open, ",") { + t.Errorf("%s: open %v, want %v", name, open, c.open) + } + } +} + +// The catalogue's own tree: every program a local build leaves is ignored, so `git add -A` never takes one. +func TestNoProgramABuildLeavesIsCommitted(t *testing.T) { + root := filepath.Join("..", "..") + programs, err := Programs(root) + if err != nil { + t.Fatal(err) + } + if len(programs) == 0 { + t.Fatal("no main package found: the check reads nothing") + } + open, err := Unignored(root, programs) + if err != nil { + t.Fatal(err) + } + for _, p := range open { + t.Errorf("%s is the program a local go build leaves, and no ignore file names it: add /%s to the catalogue's .gitignore", p, p) + } +} diff --git a/checks/gitignored/go.mod b/checks/gitignored/go.mod new file mode 100644 index 000000000..e016e7f91 --- /dev/null +++ b/checks/gitignored/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-catalog/checks/gitignored + +go 1.22 diff --git a/checks/gofmt/go.mod b/checks/gofmt/go.mod new file mode 100644 index 000000000..5d9098aa8 --- /dev/null +++ b/checks/gofmt/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-catalog/checks/gofmt + +go 1.22 diff --git a/checks/gofmt/gofmt.go b/checks/gofmt/gofmt.go new file mode 100644 index 000000000..275adb24a --- /dev/null +++ b/checks/gofmt/gofmt.go @@ -0,0 +1,58 @@ +// Package gofmt refuses a Go file of the catalogue that gofmt would change (the second of a kind in one milestone: +// buildstep's generated.go and claude-code's config_test.go reached a join unformatted). A check here holds every +// module to it, where a module's own tests hold only themselves. +package gofmt + +import ( + "bytes" + "go/format" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" +) + +// Files are the Go files under root, relative to it: every .go file but those in .git, node_modules and a testdata +// directory (a test's fixture may be anything). +func Files(root string) ([]string, error) { + var out []string + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + switch d.Name() { + case ".git", "node_modules", "testdata": + return filepath.SkipDir + } + return nil + } + if strings.HasSuffix(d.Name(), ".go") && d.Type().IsRegular() { + rel, _ := filepath.Rel(root, p) + out = append(out, filepath.ToSlash(rel)) + } + return nil + }) + sort.Strings(out) + return out, err +} + +// Unformatted are the files gofmt would change, and why for one that does not parse. +func Unformatted(root string, files []string) (map[string]string, error) { + out := map[string]string{} + for _, f := range files { + src, err := os.ReadFile(filepath.Join(root, f)) + if err != nil { + return nil, err + } + got, err := format.Source(src) + switch { + case err != nil: + out[f] = "it does not parse: " + err.Error() + case !bytes.Equal(got, src): + out[f] = "gofmt changes it: run gofmt -w " + f + } + } + return out, nil +} diff --git a/checks/gofmt/gofmt_test.go b/checks/gofmt/gofmt_test.go new file mode 100644 index 000000000..89470ab76 --- /dev/null +++ b/checks/gofmt/gofmt_test.go @@ -0,0 +1,53 @@ +package gofmt + +import ( + "os" + "path/filepath" + "testing" +) + +func TestUnformattedNamesWhatGofmtChanges(t *testing.T) { + root := t.TempDir() + for f, src := range map[string]string{ + "modules/a/a.go": "package a\n", + "modules/b/b.go": "package b\nimport (\n\t\"os\"\n\t\"fmt\"\n)\nvar _, _ = fmt.Sprint, os.Exit\n", + "modules/c/testdata/c.go": "package c", + "modules/d/node_modules/d.go": "package d", + } { + p := filepath.Join(root, f) + _ = os.MkdirAll(filepath.Dir(p), 0o755) + if err := os.WriteFile(p, []byte(src), 0o644); err != nil { + t.Fatal(err) + } + } + files, err := Files(root) + if err != nil { + t.Fatal(err) + } + got, err := Unformatted(root, files) + if err != nil { + t.Fatal(err) + } + if len(files) != 2 || len(got) != 1 || got["modules/b/b.go"] == "" { + t.Errorf("files %v, unformatted %v; want a.go and b.go read, b.go named", files, got) + } +} + +// The catalogue's own Go files are as gofmt writes them. +func TestTheCataloguesGoIsFormatted(t *testing.T) { + root := filepath.Join("..", "..") + files, err := Files(root) + if err != nil { + t.Fatal(err) + } + if len(files) == 0 { + t.Fatal("no Go file found: the check reads nothing") + } + got, err := Unformatted(root, files) + if err != nil { + t.Fatal(err) + } + for f, why := range got { + t.Errorf("%s: %s", f, why) + } +} diff --git a/checks/health/go.mod b/checks/health/go.mod index f25e878cb..333ab230e 100644 --- a/checks/health/go.mod +++ b/checks/health/go.mod @@ -2,4 +2,4 @@ module checks/health go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 diff --git a/checks/health/go.sum b/checks/health/go.sum index 2effc70b8..e69de29bb 100644 --- a/checks/health/go.sum +++ b/checks/health/go.sum @@ -1,2 +0,0 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= diff --git a/checks/manifests/go.mod b/checks/manifests/go.mod index 80aa33c77..61e75b08c 100644 --- a/checks/manifests/go.mod +++ b/checks/manifests/go.mod @@ -2,4 +2,4 @@ module checks/manifests go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 diff --git a/checks/manifests/go.sum b/checks/manifests/go.sum index 2effc70b8..e69de29bb 100644 --- a/checks/manifests/go.sum +++ b/checks/manifests/go.sum @@ -1,2 +0,0 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= diff --git a/checks/manifests/main.go b/checks/manifests/main.go index 676daa901..5d4566378 100644 --- a/checks/manifests/main.go +++ b/checks/manifests/main.go @@ -1,5 +1,5 @@ // Package manifests reads every module's manifest as the catalogue's tree holds it and refuses what no judge could -// read: a module.json that does not decode, and two manifests that name one module. +// read: a module.json that does not decode, a module.json giving one field twice, and two manifests that name one module. // // It judges no manifest (novox/hq ADR 0578 §2): the checker judges every change's manifests with the indexer's judge, the // one judge, so the field, seat, bus and catalogue rules are said once, there. Until 2026-11 this check parsed each @@ -9,6 +9,8 @@ package manifests import ( + "bytes" + "encoding/json" "fmt" "os" "path/filepath" @@ -18,7 +20,9 @@ import ( ) // Problems is what is wrong with the tree's manifests under root/modules, one line each: a module.json whose bytes are -// no manifest (catalogue.Decode: not JSON, or a field of the wrong shape), then a module named by two manifests. +// no manifest (catalogue.Decode: not JSON, or a field of the wrong shape), one giving a field twice (a decoder keeps the +// last and drops the first without a word: a merge of two branches that each added the field writes it so), then a module +// named by two manifests. func Problems(root string) ([]string, error) { files, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) if err != nil { @@ -38,6 +42,10 @@ func Problems(root string) ([]string, error) { out = append(out, fmt.Sprintf("%s: %v", rel, err)) continue } + if twice := givenTwice(raw); twice != "" { + out = append(out, fmt.Sprintf("%s: the field %s is given twice; a judge reads only the last", rel, twice)) + continue + } if named[m.Module] { out = append(out, fmt.Sprintf("%s: %s was already given; two manifests name one module", rel, m.Module)) continue @@ -46,3 +54,47 @@ func Problems(root string) ([]string, error) { } return out, nil } + +// givenTwice is the first field an object of raw gives twice, as its path (`invokes`, `build.artifacts[0].env`), or empty. +func givenTwice(raw []byte) string { + d := json.NewDecoder(bytes.NewReader(raw)) + var walk func(path string) string + walk = func(path string) string { + t, err := d.Token() + if err != nil { + return "" + } + switch t { + case json.Delim('{'): + seen := map[string]bool{} + for d.More() { + k, err := d.Token() + if err != nil { + return "" + } + key, _ := k.(string) + at := key + if path != "" { + at = path + "." + key + } + if seen[key] { + return at + } + seen[key] = true + if twice := walk(at); twice != "" { + return twice + } + } + _, _ = d.Token() + case json.Delim('['): + for i := 0; d.More(); i++ { + if twice := walk(fmt.Sprintf("%s[%d]", path, i)); twice != "" { + return twice + } + } + _, _ = d.Token() + } + return "" + } + return walk("") +} diff --git a/checks/manifests/main_test.go b/checks/manifests/main_test.go index 252fa559c..90be3b259 100644 --- a/checks/manifests/main_test.go +++ b/checks/manifests/main_test.go @@ -10,7 +10,7 @@ import ( ) // The tree's manifests are ones a judge can read, and no module is named twice. A row is a catalogue of its own: a -// manifest that is no JSON and two manifests of one module are refused, naming the file; a manifest with a field this +// manifest that is no JSON, one giving a field twice and two manifests of one module are refused, naming the file; a manifest with a field this // check does not know is not (it judges nothing: the checker does, ADR 0578 §2). The catalogue itself passes. func TestEveryManifestDecodesAndNamesOneModule(t *testing.T) { for _, row := range []struct { @@ -21,6 +21,10 @@ func TestEveryManifestDecodesAndNamesOneModule(t *testing.T) { {name: "a manifest the judge can read", files: map[string]string{"a": `{"module":"a","version":"1"}`}}, {name: "a field no part knows is the judge's to refuse", files: map[string]string{"a": `{"module":"a","version":"1","unheard":true}`}}, {name: "bytes that are no manifest", files: map[string]string{"a": `{"module":`}, refused: "modules/a/module.json"}, + {name: "a field given twice", files: map[string]string{"a": `{"module":"a","version":"1","invokes":["x.y"],"build":{"artifacts":[{}]},"invokes":["z.w"]}`}, + refused: "modules/a/module.json: the field invokes is given twice"}, + {name: "a field given twice deeper", files: map[string]string{"a": `{"module":"a","version":"1","build":{"artifacts":[{"name":"t","name":"u"}]}}`}, + refused: "the field build.artifacts[0].name is given twice"}, {name: "two manifests of one module", files: map[string]string{"a": `{"module":"x","version":"1"}`, "b": `{"module":"x","version":"1"}`}, refused: "two manifests name one module"}, } { diff --git a/checks/noplacement/main.go b/checks/noplacement/main.go index 1bcce1995..609d0ba61 100644 --- a/checks/noplacement/main.go +++ b/checks/noplacement/main.go @@ -1,6 +1,6 @@ // Command noplacement refuses what the repository path of placement left behind (novox/hq ADR 0567 §10, §12). // -// Placement, which module runs on which node, is bus state held by the seat mesh-placement; no file of the +// Placement, which module runs on which node, is bus state held by the seat mesh-module-assigner; no file of the // catalogue says it, and no manifest reads it from the delivery indexer, which no longer keeps it. It refuses: // // 1. a `placement` directory (or file) at the root of the catalogue; @@ -8,7 +8,8 @@ // 2. a `particulars` file at the root (the mesh's node names come from the checker's run, §16); // // 3. a manifest naming a state, stream, event or bucket of the indexer's placement (`mesh-delivery-indexer.placement…` -// or `mesh-delivery-indexer_placement…`) in any string value, such as `reads`; +// or `mesh-delivery-indexer_placement…`), or the assigner's retired state placement (`mesh-delivery-assigner.placement…`, +// `mesh-module-assigner.placement…`, its views and its read tool), in any string value, such as `reads`; // // The controller's assignments, which item 4 once listed readers of, are refused with every other name of the // controller by checks/controllerdeps (novox/hq ADR 0590 §5). @@ -27,14 +28,17 @@ import ( "strings" ) -// Prefixes are the names of the indexer's placement parts; a manifest string starting with one names them. -var Prefixes = []string{"mesh-delivery-indexer.placement", "mesh-delivery-indexer_placement"} +// Prefixes are the names of the indexer's placement parts and the retired names of the assigner's state, now +// assignments (novox/hq ADR 0641); a manifest string starting with one names them. +var Prefixes = []string{"mesh-delivery-indexer.placement", "mesh-delivery-indexer_placement", + "mesh-delivery-assigner.placement", "mesh-delivery-assigner_placement", "mesh-delivery-assigner.views.placement", + "mesh-delivery-assigner.assigner_read_placement", "mesh-module-assigner.placement"} // Findings is every refusal in the catalogue at root, sorted. func Findings(root string) ([]string, error) { var out []string if _, err := os.Lstat(filepath.Join(root, "placement")); err == nil { - out = append(out, "placement: the repository holds no placement; it is bus state of the seat mesh-placement (novox/hq ADR 0567 §10)") + out = append(out, "placement: the repository holds no placement; it is bus state of the seat mesh-module-assigner (novox/hq ADR 0567 §10)") } if _, err := os.Lstat(filepath.Join(root, "particulars")); err == nil { out = append(out, "particulars: the repository lists no node names; the checker hands them to the check (novox/hq ADR 0567 §16)") @@ -55,7 +59,7 @@ func Findings(root string) ([]string, error) { } module := filepath.Base(filepath.Dir(file)) for _, s := range named(doc, nil) { - out = append(out, fmt.Sprintf("%s: names %q, which the indexer no longer keeps; placement is the seat mesh-placement's (novox/hq ADR 0567 §12)", module, s)) + out = append(out, fmt.Sprintf("%s: names %q, a name that is retired: the indexer keeps no placement (novox/hq ADR 0567 §12) and the seat mesh-module-assigner's state is assignments (novox/hq ADR 0641)", module, s)) } } sort.Strings(out) @@ -99,5 +103,5 @@ func main() { if len(found) > 0 { os.Exit(1) } - fmt.Println("no placement directory, and no manifest reads the indexer's placement") + fmt.Println("no placement directory, and no manifest names the retired placement") } diff --git a/checks/noplacement/main_test.go b/checks/noplacement/main_test.go index 0db98f067..3bc855039 100644 --- a/checks/noplacement/main_test.go +++ b/checks/noplacement/main_test.go @@ -13,13 +13,17 @@ func TestFindingsRefuseWhatThePlacementPathLeft(t *testing.T) { files map[string]string want string // empty: clean }{ - {"clean", map[string]string{"modules/a/module.json": `{"module":"a","reads":["mesh-delivery-assigner.placement"]}`}, ""}, + {"clean", map[string]string{"modules/a/module.json": `{"module":"a","reads":["mesh-delivery-assigner.assignments"]}`}, ""}, {"a placement directory", map[string]string{"placement/ace.json": `{}`}, "placement: the repository holds no placement"}, {"a particulars file", map[string]string{"particulars": "ace\n"}, "particulars: the repository lists no node names"}, {"a read of the indexer's placement", map[string]string{"modules/a/module.json": `{"module":"a","reads":["mesh-delivery-indexer.placement"]}`}, `a: names "mesh-delivery-indexer.placement"`}, {"a read of its candidates", map[string]string{"modules/b/module.json": `{"module":"b","reads":["mesh-delivery-indexer.placement-candidates"]}`}, "placement-candidates"}, {"its event", map[string]string{"modules/c/module.json": `{"module":"c","on":[{"event":"mesh-delivery-indexer.placement-changed"}]}`}, "placement-changed"}, - {"the seat's own state is allowed", map[string]string{"modules/d/module.json": `{"module":"d","reads":["mesh-placement.placement"]}`}, ""}, + {"the seat's own state is allowed", map[string]string{"modules/d/module.json": `{"module":"d","reads":["mesh-module-assigner.assignments"]}`}, ""}, + {"the assigner's retired state", map[string]string{"modules/e/module.json": `{"module":"e","reads":["mesh-delivery-assigner.placement"]}`}, `e: names "mesh-delivery-assigner.placement"`}, + {"its retired views family", map[string]string{"modules/f/module.json": `{"module":"f","consumes":["mesh-delivery-assigner.views.placement"]}`}, "views.placement"}, + {"its retired read tool", map[string]string{"modules/g/module.json": `{"module":"g","invokes":["mesh-delivery-assigner.assigner_read_placement"]}`}, "assigner_read_placement"}, + {"the seat's retired verb", map[string]string{"modules/h/module.json": `{"module":"h","invokes":["mesh-module-assigner.placement"]}`}, "mesh-module-assigner.placement"}, } { t.Run(row.name, func(t *testing.T) { root := t.TempDir() diff --git a/checks/owned/go.mod b/checks/owned/go.mod index ec64d678b..b71c15095 100644 --- a/checks/owned/go.mod +++ b/checks/owned/go.mod @@ -2,6 +2,6 @@ module git.novox.be/novox/mesh-catalog/checks/owned go 1.22 -require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0 +require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0-00010101000000-000000000000 replace git.novox.be/novox/mesh-catalog/checks/trunk => ../trunk diff --git a/checks/paths/go.mod b/checks/paths/go.mod index 630a93ff7..0f5152271 100644 --- a/checks/paths/go.mod +++ b/checks/paths/go.mod @@ -2,6 +2,6 @@ module git.novox.be/novox/mesh-catalog/checks/paths go 1.22 -require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0 +require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0-00010101000000-000000000000 replace git.novox.be/novox/mesh-catalog/checks/trunk => ../trunk diff --git a/checks/predefined/go.mod b/checks/predefined/go.mod index 00332a587..f06f633c1 100644 --- a/checks/predefined/go.mod +++ b/checks/predefined/go.mod @@ -2,6 +2,6 @@ module git.novox.be/novox/mesh-catalog/checks/predefined go 1.22 -require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0 +require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0-00010101000000-000000000000 replace git.novox.be/novox/mesh-catalog/checks/trunk => ../trunk diff --git a/checks/readable/go.mod b/checks/readable/go.mod index ff7332412..87c149ca9 100644 --- a/checks/readable/go.mod +++ b/checks/readable/go.mod @@ -2,4 +2,13 @@ module checks/readable go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require ( + git.novox.be/novox/mesh-catalog/modules/disk-load/diskreadings v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-catalog/modules/restic/backupgood v0.0.0-00010101000000-000000000000 +) + +replace git.novox.be/novox/mesh-catalog/modules/disk-load/diskreadings => ../../modules/disk-load/diskreadings + +replace git.novox.be/novox/mesh-catalog/modules/restic/backupgood => ../../modules/restic/backupgood + +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 // indirect diff --git a/checks/readable/go.sum b/checks/readable/go.sum index 2effc70b8..e69de29bb 100644 --- a/checks/readable/go.sum +++ b/checks/readable/go.sum @@ -1,2 +0,0 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= diff --git a/checks/readable/main.go b/checks/readable/main.go index 8fa81fba9..b455f0f35 100644 --- a/checks/readable/main.go +++ b/checks/readable/main.go @@ -6,7 +6,7 @@ // the owner's manifest went with clause 5, and announcetest holds a module to its own list, not to its readers. // // An owner announces from its code. A Go module announces when its code imports mesh-sdk's go/announce: every -// announce.State literal there is read, its Name a string, a constant of the package, or a constant of mesh-sdk this +// announce.State literal there is read, its Name a string, a constant of the package, or a constant of an owner's package this // check knows (Known). A TypeScript module announces when its code says the event `states` or calls the SDK's keeps: // every `{ name: "...", readable: true }` there is read. An owner that announces nothing from code is read as before // on the mesh, and is not judged; neither is an owner outside the catalogue (another repository's). @@ -29,13 +29,15 @@ import ( "strconv" "strings" - "git.novox.be/novox/mesh-sdk/go/health" + "git.novox.be/novox/mesh-catalog/modules/disk-load/diskreadings" + "git.novox.be/novox/mesh-catalog/modules/restic/backupgood" ) -// Known are the mesh-sdk constants an announcement may name a state by, by the selector the code writes. +// Known are the constants of the owners' published packages an announcement may name a state by, by the selector the +// code writes: disk-load's readings and restic's last good night (ADR 0634 §4: a reading's layout is its owner's). var Known = map[string]string{ - "health.GoodState": health.GoodState, - "health.ReadingsState": health.ReadingsState, + "backupgood.GoodState": backupgood.GoodState, + "diskreadings.ReadingsState": diskreadings.ReadingsState, } const announcePkg = `"git.novox.be/novox/mesh-sdk/go/announce"` diff --git a/checks/readable/main_test.go b/checks/readable/main_test.go index 3a15e322c..808b5326e 100644 --- a/checks/readable/main_test.go +++ b/checks/readable/main_test.go @@ -30,7 +30,7 @@ func TestFindings(t *testing.T) { {"go, a known mesh-sdk name and readers", map[string]string{ "modules/r/module.json": `{"module":"r","reads":["o.good","o.s"]}`, "modules/o/module.json": `{"module":"o"}`, - "modules/o/cmd/o/main.go": goOwner + "var _ = []announce.State{{Name: health.GoodState, Readable: true}, {Name: \"s\", Readers: []string{\"r\"}}}\n"}, ""}, + "modules/o/cmd/o/main.go": goOwner + "var _ = []announce.State{{Name: backupgood.GoodState, Readable: true}, {Name: \"s\", Readers: []string{\"r\"}}}\n"}, ""}, {"go, a name that cannot be read", map[string]string{ "modules/r/module.json": `{"module":"r","reads":["o.s"]}`, "modules/o/module.json": `{"module":"o"}`, diff --git a/checks/requiredmodules/go.mod b/checks/requiredmodules/go.mod new file mode 100644 index 000000000..2e7734205 --- /dev/null +++ b/checks/requiredmodules/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-catalog/checks/requiredmodules + +go 1.22 diff --git a/checks/requiredmodules/main.go b/checks/requiredmodules/main.go new file mode 100644 index 000000000..9fc63a36c --- /dev/null +++ b/checks/requiredmodules/main.go @@ -0,0 +1,204 @@ +// Command requiredmodules refuses a Go module that imports a package of a mesh module its go.mod does not require +// (issue 1921, issue 1966; the second time a local workspace hid it: the candidate's build resolves only what go.mod says). +// +// A developer's go.work resolves every sibling module, so a source can import a package whose module the go.mod never +// names and still build; the build seat builds against the go.mod alone and then fails with "finding module for +// package". This check reads every go.mod under the roots it reports and every Go source beside it (test files and files +// behind a build tag included, testdata, vendor and node_modules left out) and, for each import under +// git.novox.be/novox/, finds the module that owns the package (the longest `module` line, among the modules of every +// root it is given, that is a prefix of the import) and demands that module to be the go.mod's own or one it requires. +// Where no known module owns the import, any own or required module that is a prefix of it satisfies it. +// +// go run . [ ...] +// +// The first root is reported; the others (the repositories cloned beside, MESH_CHECK_BESIDE) only say which module owns +// which path. With -all every root is reported. Exits 1 on a finding. +package main + +import ( + "fmt" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +const prefix = "git.novox.be/novox/" + +// Mod is a go.mod: where it is, its module path and the modules it requires. +type Mod struct { + Dir string + Path string + Requires map[string]bool +} + +// Modules reads every go.mod under root. +func Modules(root string) ([]Mod, error) { + var out []Mod + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + switch d.Name() { + case "testdata", "vendor", "node_modules", ".git": + return fs.SkipDir + } + return nil + } + if d.Name() != "go.mod" { + return nil + } + raw, err := os.ReadFile(p) + if err != nil { + return err + } + m := Mod{Dir: filepath.Dir(p), Requires: map[string]bool{}} + in := false + for _, line := range strings.Split(string(raw), "\n") { + line = strings.TrimSpace(line) + if i := strings.Index(line, "//"); i >= 0 { + line = strings.TrimSpace(line[:i]) + } + f := strings.Fields(line) + switch { + case len(f) == 2 && f[0] == "module": + m.Path = strings.Trim(f[1], `"`) + case len(f) >= 1 && f[0] == "require" && len(f) >= 2 && f[1] == "(": + in = true + case line == ")": + in = false + case len(f) >= 3 && f[0] == "require": + m.Requires[f[1]] = true + case in && len(f) >= 2: + m.Requires[f[0]] = true + } + } + if m.Path == "" { + return fmt.Errorf("%s names no module", p) + } + out = append(out, m) + return nil + }) + return out, err +} + +func within(path, module string) bool { return path == module || strings.HasPrefix(path, module+"/") } + +// Findings reports, for every go.mod under the report roots, each imported package whose module the go.mod neither is nor +// requires; known are the other roots that say which module owns a path. +func Findings(report, known []string) ([]string, error) { + var owners []string + var reported []Mod + for _, r := range report { + ms, err := Modules(r) + if err != nil { + return nil, err + } + reported = append(reported, ms...) + } + for _, r := range append(append([]string{}, report...), known...) { + ms, err := Modules(r) + if err != nil { + return nil, err + } + for _, m := range ms { + owners = append(owners, m.Path) + } + } + var out []string + for _, m := range reported { + seen := map[string]bool{} + err := filepath.WalkDir(m.Dir, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + switch d.Name() { + case "testdata", "vendor", "node_modules", ".git": + return fs.SkipDir + } + if p != m.Dir { + if _, err := os.Stat(filepath.Join(p, "go.mod")); err == nil { + return fs.SkipDir // a nested module is judged by its own go.mod + } + } + return nil + } + if !strings.HasSuffix(p, ".go") { + return nil + } + f, err := parser.ParseFile(token.NewFileSet(), p, nil, parser.ImportsOnly) + if err != nil { + return fmt.Errorf("%s: %w", p, err) + } + for _, im := range f.Imports { + path, _ := strconv.Unquote(im.Path.Value) + if !strings.HasPrefix(path, prefix) || seen[path] { + continue + } + owner := "" + for _, o := range owners { + if within(path, o) && len(o) > len(owner) { + owner = o + } + } + ok := false + if owner != "" { + ok = owner == m.Path || m.Requires[owner] + } else { + ok = within(path, m.Path) + for r := range m.Requires { + ok = ok || within(path, r) + } + } + if !ok { + seen[path] = true + if owner == "" { + owner = "(its module)" + } + rel, _ := filepath.Rel(m.Dir, p) + out = append(out, fmt.Sprintf("%s: %s imports %s (module %s), which its go.mod does not require", filepath.Join(m.Dir, "go.mod"), rel, path, owner)) + } + } + return nil + }) + if err != nil { + return nil, err + } + } + sort.Strings(out) + return out, nil +} + +func main() { + args := os.Args[1:] + all := len(args) > 0 && args[0] == "-all" + if all { + args = args[1:] + } + if len(args) < 1 { + fmt.Fprintln(os.Stderr, "usage: requiredmodules [-all] [ ...]") + os.Exit(2) + } + report, known := args[:1], args[1:] + if all { + report, known = args, nil + } + found, err := Findings(report, known) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + for _, f := range found { + fmt.Println(f) + } + if len(found) > 0 { + os.Exit(1) + } + fmt.Println("every module requires the mesh modules it imports") +} diff --git a/checks/requiredmodules/main_test.go b/checks/requiredmodules/main_test.go new file mode 100644 index 000000000..3ea1ebbc1 --- /dev/null +++ b/checks/requiredmodules/main_test.go @@ -0,0 +1,78 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func tree(t *testing.T, files map[string]string) string { + t.Helper() + root := t.TempDir() + for p, c := range files { + full := filepath.Join(root, p) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(c), 0o644); err != nil { + t.Fatal(err) + } + } + return root +} + +const aMod = "module git.novox.be/novox/r/a\n\ngo 1.22\n" +const bMod = "module git.novox.be/novox/r/b/sub\n\ngo 1.22\n" + +// A module that imports a package of a mesh module its go.mod does not require is refused, in a test file and behind a +// build tag too; the module's own packages, a required module's and a nested module's (only when required) are not. +func TestAModuleImportsOnlyWhatItRequires(t *testing.T) { + src := func(imp string) string { return "package x\n\nimport _ \"" + imp + "\"\n" } + for _, row := range []struct { + name string + files map[string]string + want string + }{ + {"its own package", map[string]string{"a/go.mod": aMod, "a/x.go": src("git.novox.be/novox/r/a/other")}, ""}, + {"a required module", map[string]string{"a/go.mod": aMod + "\nrequire git.novox.be/novox/r/b/sub v0.0.0\n", "a/x.go": src("git.novox.be/novox/r/b/sub/pkg"), "b/go.mod": bMod}, ""}, + {"a required module in a block", map[string]string{"a/go.mod": aMod + "\nrequire (\n\tgit.novox.be/novox/r/b/sub v0.0.0 // indirect\n)\n", "a/x.go": src("git.novox.be/novox/r/b/sub/pkg"), "b/go.mod": bMod}, ""}, + {"a module not required", map[string]string{"a/go.mod": aMod, "a/x.go": src("git.novox.be/novox/r/b/sub/pkg"), "b/go.mod": bMod}, "x.go imports git.novox.be/novox/r/b/sub/pkg"}, + {"a test file", map[string]string{"a/go.mod": aMod, "a/x_test.go": src("git.novox.be/novox/r/b/sub/pkg"), "b/go.mod": bMod}, "x_test.go imports"}, + {"an integration file", map[string]string{"a/go.mod": aMod, "a/x.go": "//go:build integration\n\n" + src("git.novox.be/novox/r/b/sub/pkg"), "b/go.mod": bMod}, "x.go imports"}, + {"a module the longest owner is not", map[string]string{"a/go.mod": aMod + "\nrequire git.novox.be/novox/r/b v0.0.0\n", "a/x.go": src("git.novox.be/novox/r/b/sub/pkg"), "b/go.mod": "module git.novox.be/novox/r/b\n", "b/sub/go.mod": bMod}, "does not require"}, + {"a testdata fixture", map[string]string{"a/go.mod": aMod, "a/testdata/x.go": src("git.novox.be/novox/r/b/sub/pkg"), "a/x.go": "package x\n"}, ""}, + {"an outside module", map[string]string{"a/go.mod": aMod, "a/x.go": src("github.com/x/y")}, ""}, + } { + t.Run(row.name, func(t *testing.T) { + got, err := Findings([]string{tree(t, row.files)}, nil) + if err != nil { + t.Fatal(err) + } + if all := strings.Join(got, "\n"); (row.want == "") != (all == "") || !strings.Contains(all, row.want) { + t.Fatalf("findings = %q, want one containing %q", all, row.want) + } + }) + } +} + +// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): the repositories cloned beside it, when +// the check says where, tell which module owns a path. +func TestEveryCatalogueModuleRequiresWhatItImports(t *testing.T) { + var known []string + if beside := os.Getenv("MESH_CHECK_BESIDE"); beside != "" { + es, _ := os.ReadDir(beside) + for _, e := range es { + if e.IsDir() && e.Name() != "mesh-catalog" { + known = append(known, filepath.Join(beside, e.Name())) + } + } + } + got, err := Findings([]string{filepath.Join("..", "..")}, known) + if err != nil { + t.Fatal(err) + } + for _, f := range got { + t.Error(f) + } +} diff --git a/checks/requiredmodules/reads-outside b/checks/requiredmodules/reads-outside new file mode 100644 index 000000000..4300f90f9 --- /dev/null +++ b/checks/requiredmodules/reads-outside @@ -0,0 +1,6 @@ +# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole; +# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept +# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing. +# every go.mod and Go source of the modules and of the checks, whose imports and requires are read. +modules +checks diff --git a/checks/secrets/go.mod b/checks/secrets/go.mod index c775c6843..a70ced3f1 100644 --- a/checks/secrets/go.mod +++ b/checks/secrets/go.mod @@ -2,6 +2,6 @@ module git.novox.be/novox/mesh-catalog/checks/secrets go 1.22 -require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0 +require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0-00010101000000-000000000000 replace git.novox.be/novox/mesh-catalog/checks/trunk => ../trunk diff --git a/checks/templates/go.mod b/checks/templates/go.mod index 60962f191..123233920 100644 --- a/checks/templates/go.mod +++ b/checks/templates/go.mod @@ -2,6 +2,6 @@ module git.novox.be/novox/mesh-catalog/checks/templates go 1.22 -require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0 +require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0-00010101000000-000000000000 replace git.novox.be/novox/mesh-catalog/checks/trunk => ../trunk diff --git a/checks/words/main_test.go b/checks/words/main_test.go index 2f05f0ed1..5cd8cde67 100644 --- a/checks/words/main_test.go +++ b/checks/words/main_test.go @@ -109,7 +109,7 @@ func TestCheckAddressesRefusesTheControllersAddresses(t *testing.T) { {"`mesh-controller.`", true}, {"the mesh-controller module is gone.", false}, {"located in mesh-controller#246", false}, - {"ask mesh-placement.placement", false}, + {"ask mesh-module-assigner.assignments", false}, } { got := checkAddresses([]text{{file: "m/a.go", line: 1, s: c.s}}) if (len(got) == 1) != c.want { diff --git a/health-undeclared b/health-undeclared index ad3ead712..192c90fb6 100644 --- a/health-undeclared +++ b/health-undeclared @@ -1,4 +1,4 @@ -58 +56 The long-running resources of this catalogue that do not say how they are ready (`health`, novox/hq ADR 0240 rule 8), as `module check` counts them. It may only go down: checks/health fails a change that raises it, and one that lowers it writes the new number on the first line. From 2026-11-18, or once it is 0, a long-running diff --git a/modules/adwaita/go.mod b/modules/adwaita/go.mod index fadc17468..7454d9d43 100644 --- a/modules/adwaita/go.mod +++ b/modules/adwaita/go.mod @@ -2,10 +2,10 @@ module adwaita go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/adwaita/go.sum b/modules/adwaita/go.sum index 02d6daae7..0daef11ff 100644 --- a/modules/adwaita/go.sum +++ b/modules/adwaita/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/artifact-store-tools/README.md b/modules/artifact-store-tools/README.md index 5a38db8f5..cc662262c 100644 --- a/modules/artifact-store-tools/README.md +++ b/modules/artifact-store-tools/README.md @@ -6,38 +6,34 @@ directory and the file the mesh writes there from its setting `older-store-names ## Why a module beside the store -The store is the `distribution` module. A module that provides the artifact store cannot also build an -artifact: building publishes to the store, so the store would be needed to create itself, and the -controller's module check refuses that manifest. So the store's tools are a second module, as the -controller's refusal says to do. **Assign it to the machine that holds the store**: its tools read the -store's files through the store's own container, and anywhere else they answer that the container is not -on that machine. +The store is the `distribution` module, the registry seats' holder. These tools became a module of their own when the +indexer's judge refused a store holder that builds an artifact; that rule is gone (issue 1966), and the sweep stays its +own module, holding the seat `mesh-artifact-sweep`. It requires no provision: what it holds, releases and removes it +asks the registry seats `oci-artifact-registry` and `container-image-registry` (see *The sweep through the registry +seats*), and what the store holds it reads through the same seats' `list` (novox/hq ADR 0632 §4): it reads neither the +store's files nor its read side, so it runs on any machine. ## Tools | tool | | what | |---|---|---| | `artifact_store_repositories` | r | every repository: tags and what each names, how many manifests (tagged or not), size | -| `artifact_store_usage` | r | the store's size, the largest repositories, bytes shared between repositories, bytes no manifest marks (what the nightly collector frees next) | +| `artifact_store_usage` | r | the store's size, each manifest's as its seat says it, and the largest repositories | | `artifact_store_references` | r | what the controller's records and the catalogue's definitions say of each manifest, counted and sized by state and repository; each manifest listed when one repository is asked; what the records keep that the store does not hold | | `artifact_store_collect` | a | a dry run unless `dry_run` is false: what the controller would let go of, and the bytes the nightly collector would free then and now. A real run needs `why` and asks the controller's `collect` | ### How the store is read -The store's door lists repositories and tags, but not a manifest no tag names, and the mesh pins every -machine by digest, so that is most of them. So the bundle reads the store's own files through its own -container (`docker exec mesh-registry`, busybox `find`, `stat` and `cat`), and only reads them: every -blob with its size, every manifest each repository holds, what each tag names, and each manifest's -content, four hundred to an exec. What it read it keeps (a manifest is named by its content, so it never -changes), and the next call reads only what is new. A manifest that could not be read is counted and -said; what it marks beyond itself is then unknown, so sizes may read low and freed bytes high, and the -answer says so. +The store's read side lists repositories and tags, but not a manifest no tag names, and the mesh pins every +machine by digest, so that is most of them. So the bundle asks the two registry seats' `list` +(`container-image-registry` for images and indexes, `oci-artifact-registry` for bundles and archives), which the +store's holder answers from the store's own files: each manifest's digest, whether it is an index and the manifests it +names, the tags naming it, its size and whether a hold keeps it (novox/hq ADR 0632 §4). -docker runs as the tool runner's account; a socket that refuses it is asked again through `sudo -n`, -never with a prompt, as the container runtime's own tools do. - -A manifest *marks* its own content, its configuration and its layers, and an index marks the manifests -it lists. The store's collector removes every blob no manifest marks, so these numbers are its own. +A manifest's size is its own content and every blob it names, as its seat says it, so a layer two manifests share +is counted in each, and the bytes the store's nightly collector frees are the sizes of the manifests that go, an +upper bound. The address the bundle says the store is at comes from `${seat:container-image-registry:reach}`, the +image seat holder's listen (ADR 0632 §1), and is used for the catalogue's definitions only, never to reach the store. ### The states of a manifest @@ -99,19 +95,27 @@ manifest that stays and would lose a platform it names — to be read before a r go test ./... ``` -Against a fake container that prints what the two scripts would: the listing parsed (a repository name -with slashes, a cut listing refused), manifests read with one absent or unreadable said as unread, a -digest never becoming a path outside the blobs, the mark (an index, a shared blob, a blob nothing marks), -every state, what the records keep that the store lacks, bytes freed counting a blob shared with a kept -manifest as kept, a dry run never confirming, a real run without `why` refused before anything is asked, +Against a fake of the two registry seats' `list`: the store read through it (an index and the manifests it names, a +bundle's holder, a hold, the seat's sizes), every state, what the records keep that the store lacks, bytes freed as the +sizes of what goes, a dry run never confirming, a real run without `why` refused before anything is asked, the fall-back when the references let go of cannot be carried, a manifest a definition pins under the -store's current or an older name, a definition not read said first, escalation through `sudo -n`, and that the +store's current or an older name, a definition not read said first, and that the tools served are exactly the manifest's `tools` and its `invokes` exactly the two controller verbs and the catalogue's two tools. -Any command other than the two read-only scripts fails the test. +Any ask other than a seat's `list` fails the read tools' tests. ## Files it does not know Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416). - `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2). + +### The sweep through the registry seats + +The sweep writes nothing to the store's door (CAT-AS1, ADR 0617 §2): it asks the registry seats the store's holder +serves. Each kept bundle or archive is held on `oci-artifact-registry` and each kept copy of an upstream base on +`container-image-registry` (`hold`, with why it is kept) before anything goes; each eligible one is released, then +removed for `retention` on its seat (`release`, `remove`). It reads a reference in both forms, `artifact-store://` +and `//@`, as one artifact, and says artifacts-gone in the form the build named it. A +call a seat refuses stops the sweep and writes the finding `sweep-refused` in the module's findings; the next sweep no +seat refuses clears it (`sweepseats.go`). diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/definitions_test.go b/modules/artifact-store-tools/cmd/artifact-store-tools/definitions_test.go index c46cb12ca..739c904d2 100644 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/definitions_test.go +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/definitions_test.go @@ -152,7 +152,7 @@ func TestOlderNamesNotReadAreSaidFirst(t *testing.T) { // references is one artifact_store_references tool, kept across calls as the runtime keeps the bundle. func references(t *testing.T, a *asked) func(map[string]any) (any, error) { f := world() - s := &Store{Container: "mesh-registry", Run: f.run(t)} + s := &Store{Ask: f.ask(t)} for _, tl := range Tools(s, recordsAsked{a}, &Catalogue{Ask: a.ask}) { if tl.Name == "artifact_store_references" { return tl.Run diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/door.go b/modules/artifact-store-tools/cmd/artifact-store-tools/door.go index 4335d7868..e9292f5fe 100644 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/door.go +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/door.go @@ -1,41 +1,57 @@ package main -// The artifact store's door and the bus, as the holder reaches them through this module's runtime. +// The artifact store, through its seats, and the bus, as the holder reaches them through this module's runtime. import ( "context" "encoding/json" + "errors" "fmt" "os" "strings" + "sync/atomic" "time" stdio "git.novox.be/novox/mesh-sdk/go" - "artifactstoretools/internal/registry" + "git.novox.be/novox/mesh-sdk/go/views" ) -// door is registry.Store at the store's address as this machine reaches it. -type door struct{ s registry.Store } +// Gone is the answer when the store does not hold it: the outcome wanted, already true. +var Gone = errors.New("the store does not hold it") -func (d *door) Address() string { return d.s.Address } -func (d *door) Hold(ctx context.Context, r string) (bool, error) { return d.s.Hold(ctx, r) } -func (d *door) Holds(ctx context.Context, r string) error { return d.s.Holds(ctx, r) } -func (d *door) IsIndex(ctx context.Context, r string) (bool, error) { - return d.s.IsIndex(ctx, r) +// ErrNotOurs is a reference this sweep will not address: not the mesh's own, or naming nothing the store holds by digest. +// +// **A fact about the record, not about the store** (novox/hq issue 226). The two deserve opposite responses (skip one and +// go on, abandon the sweep for the other), and collapsing them into "an error" is how a cautious loop became one that did +// nothing while reporting the right number. +var ErrNotOurs = errors.New("not a reference into the mesh's artifact store") + +// door is the store as its seats' list answers it (ADR 0632 §4), at the address this machine reaches it by: it reads +// only; what the sweep holds and removes it asks the registry seats too (sweepseats.go). +type door struct { + address string + store *Store } -func (d *door) LetGo(ctx context.Context, r string) error { return d.s.LetGo(ctx, r) } -func (d *door) SpareKept(ctx context.Context, keptImages, touching []string) error { - spare, err := d.s.SpareKeptIndexes(ctx, keptImages, touching) - if err == nil { - d.s.Spare = spare - } + +func (d *door) Address() string { return d.address } + +// Holds is whether the store holds what a reference names: an image's manifest, a bundle's or an archive's blob. Gone when +// its seat lists none (ADR 0526 clause 3: a registration by hand is refused at bytes that are not there). +func (d *door) Holds(_ context.Context, r string) error { + _, err := d.store.listed(r) return err } -// DoorAt reads the store's address (host:port) from the file the manifest fills from the seat mesh-artifact-store's -// reach, each time, so a store that moved is reached where it is now. -func DoorAt(file string) func() (Door, error) { +// IsIndex is whether a reference is an index the store holds, as its seat lists it. Gone when it does not hold it. +func (d *door) IsIndex(_ context.Context, r string) (bool, error) { + e, err := d.store.listed(r) + return e.Index, err +} + +// DoorAt reads the store's address (host:port) from the file the manifest fills from the seat container-image-registry's +// reach, each time, so a store that moved is said where it is now; the store is read through s. +func DoorAt(file string, s *Store) func() (Door, error) { return func() (Door, error) { if file == "" { return nil, fmt.Errorf("this bundle was given no file naming the artifact store's address") @@ -48,15 +64,27 @@ func DoorAt(file string) func() (Door, error) { if address == "" { return nil, fmt.Errorf("the artifact store's address is empty in %s", file) } - return &door{s: registry.Store{Address: address}}, nil + return &door{address: address, store: s}, nil } } // runtimeBus is the holder's bus through the runtime: its reads, its findings and its seat's event. -type runtimeBus struct{} +type runtimeBus struct{ views *views.Reader } -func (runtimeBus) Keys(state string) ([]string, error) { return stdio.State(state).Keys() } -func (runtimeBus) Get(state, key string) ([]byte, bool, error) { +// followed says whether the state is the indexer's or the assigner's, which the holder asks once and follows by events +// (ADR 0615) rather than reads. +func (b runtimeBus) followed(state string) bool { return b.views != nil && b.views.Follows(state) } + +func (b runtimeBus) Keys(state string) ([]string, error) { + if b.followed(state) { + return b.views.Keys(state) + } + return stdio.State(state).Keys() +} +func (b runtimeBus) Get(state, key string) ([]byte, bool, error) { + if b.followed(state) { + return b.views.Get(state, key) + } e, err := stdio.State(state).Get(key) if err != nil || e == nil { return nil, false, err @@ -90,7 +118,7 @@ func (runtimeBus) Publish(subject string, body any) error { // follow watches the builder's done keys and the wants: a build's end sweeps and // observes again; a want changed observes again. Event-driven: nothing reads them on a timer. -func follow(h *Holder) { +func follow(h *Holder, v *views.Reader) { <-stdio.Served() watch := func(state string, handle func(stdio.StateChange) error) { for { @@ -119,11 +147,22 @@ func follow(h *Holder) { } return nil }) - watch(WantsRead, func(c stdio.StateChange) error { - if !c.Current { - go observe() + // The wants are the assigner's: the first view then its events (ADR 0615). What Watch hands over before it returns + // is the first view, not a change. + var current atomic.Bool + current.Store(true) + for { + err := v.Watch(WantsRead, func(string, []byte, bool) { + if !current.Load() { + go observe() + } + }) + if err == nil { + break } - return nil - }) + h.logf("watching %s: %v; asked again in 10s", WantsRead, err) + time.Sleep(10 * time.Second) + } + current.Store(false) observe() } diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/gone.go b/modules/artifact-store-tools/cmd/artifact-store-tools/gone.go new file mode 100644 index 000000000..cca4a96df --- /dev/null +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/gone.go @@ -0,0 +1,39 @@ +package main + +import ( + "encoding/json" + "fmt" + "sort" + + sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep" +) + +// The holders whose states this module reads, one constant each (a reader names its holder once; ADR 0634 §4). +const ( + assignerModule = "mesh-delivery-assigner" + indexerModule = "mesh-delivery-indexer" +) + +// The seat mesh-artifact-sweep types the answer of its verb held and the body of its event artifacts-gone; this module +// holds the seat, so it answers and says them in those types. +type ( + // HeldAnswer is the answer of the seat's verb held. + HeldAnswer = sweep.HeldAnswer + // ArtifactsGone is the body of the seat's event artifacts-gone. + ArtifactsGone = sweep.ArtifactsGone +) + +// GoneSubject is where the seat says artifacts-gone. +const GoneSubject = sweep.SubjectArtifactsGone + +// EncodeArtifactsGone is the event's body, the references sorted; it refuses one naming no module, no fingerprint or no +// gone reference. +func EncodeArtifactsGone(e ArtifactsGone) ([]byte, error) { + e.Event = sweep.EventArtifactsGone + e.Gone = append([]string(nil), e.Gone...) + sort.Strings(e.Gone) + if e.Module == "" || e.Fingerprint == "" || len(e.Gone) == 0 { + return nil, fmt.Errorf("%s names a module, a fingerprint and at least one gone reference", sweep.EventArtifactsGone) + } + return json.Marshal(e) +} diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/holder.go b/modules/artifact-store-tools/cmd/artifact-store-tools/holder.go index ee0c9242d..fc598c633 100644 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/holder.go +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/holder.go @@ -18,33 +18,37 @@ import ( "encoding/json" "errors" "fmt" + engineclient "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + meshwants "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" + builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "sort" "strings" "sync" "time" + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" + meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + artifactref "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient/reference" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/handacts" - "git.novox.be/novox/mesh-sdk/go/health" - "artifactstoretools/internal/registry" + "git.novox.be/novox/mesh-sdk/go/views" ) // What the holder reads and keeps. const ( - WantsRead = delivery.AssignerModule + ".wants" - BuildsRead = delivery.BuilderModule + ".builds" + WantsRead = assignerModule + ".wants" + BuildsRead = builds.BuilderModule + ".builds" HavesRead = "node-engine.have" // ManifestsRead is the indexer's manifests: the images a manifest pins by itself. - ManifestsRead = delivery.IndexerModule + ".manifests" - // FindingsState is the holder's own findings (health.FindingsState): image-missing. - FindingsState = health.FindingsState + ManifestsRead = indexerModule + "." + catalog.StateManifests + // FindingsState is the holder's own findings (meshconditions.StateFindings): image-missing. + FindingsState = meshconditions.StateFindings // Module is this module, the observer its findings name. Module = "artifact-store-tools" - // GoneSubject is where the seat says artifacts-gone. - GoneSubject = "mesh.seat." + catalogue.ArtifactSweepSeat + ".event." + delivery.ArtifactsGoneEvent ) // The sweep's bounds, the controller's (cmd/mesh-controller/collect.go mostPerSweep, sweepBudget). @@ -69,26 +73,25 @@ type Bus interface { Publish(subject string, body any) error } -// Door is the artifact store's own door: registry.Store, behind an interface so a test needs no store. +// Door is the artifact store as its seats' list answers it, read only, behind an interface so a test needs no store. What +// the sweep holds and removes it asks the registry seats (Seats, sweepseats.go). type Door interface { Address() string - Hold(ctx context.Context, reference string) (bool, error) Holds(ctx context.Context, reference string) error IsIndex(ctx context.Context, reference string) (bool, error) - LetGo(ctx context.Context, reference string) error - // SpareKept sets the platform manifests a kept index names aside before an index is let go of (ADR 0257). - SpareKept(ctx context.Context, keptImages, touching []string) error } // Holder is the seat's holder. type Holder struct { Bus Bus Door func() (Door, error) - // Listed is the store's own listing now (Store.View); nil reads none, and then nothing is said gone or collected. + // Listed is the store as its seats list it now (Store.View); nil reads none, and then nothing is said gone or collected. Listed func(ctx context.Context) (*View, error) - Acts handacts.Recorder - Now func() time.Time - Logf func(string, ...any) + Acts meshhandactsclient.Recorder + // Seats asks a registry seat's verb, `seat:.` (hold, remove): the runtime's stdio.Ask in main. + Seats func(key string, body any) (json.RawMessage, error) + Now func() time.Time + Logf func(string, ...any) sweep sync.Mutex // one sweep at a time mu sync.Mutex @@ -99,7 +102,7 @@ type Holder struct { type Build struct { Module, FP, Key string At time.Time - Artifacts []delivery.BuiltArtifact + Artifacts []builds.BuiltArtifact // Manifest is the digest of the manifest it built, in the indexer's `manifests`. Manifest string } @@ -112,7 +115,7 @@ func (h *Holder) Builds() ([]Build, error) { } var out []Build for _, k := range keys { - module, fp, err := delivery.ParseBuilderBuildKey(k) + module, fp, err := builds.ParseBuilderBuildKey(k) if err != nil { continue } @@ -123,8 +126,8 @@ func (h *Holder) Builds() ([]Build, error) { if !found { continue } - b, err := delivery.DecodeBuilderBuild(raw) - if err != nil || b.State != delivery.BuildDone { + b, err := builds.DecodeBuilderBuild(raw) + if err != nil || b.State != builds.BuildDone { continue } at, err := h.endOf(k) @@ -149,7 +152,7 @@ func (h *Holder) endOf(key string) (time.Time, error) { return time.Time{}, fmt.Errorf("the history of the builder's build %s: %w", key, err) } for _, r := range revs { - if b, err := delivery.DecodeBuilderBuild(r.Value); err == nil && b.State == delivery.BuildDone && r.At.After(at) { + if b, err := builds.DecodeBuilderBuild(r.Value); err == nil && b.State == builds.BuildDone && r.At.After(at) { at = r.At } } @@ -181,11 +184,11 @@ func (h *Holder) Wants() ([]Want, error) { if err != nil { return nil, fmt.Errorf("the want %s: %w", k, err) } - var w delivery.Want - if !found || json.Unmarshal(raw, &w) != nil || w.Removed || w.FP == "" { + var w meshwants.WantsByWantNodeModuleValue + if !found || json.Unmarshal(raw, &w) != nil || w.Removed || w.Fp == "" { continue } - out = append(out, Want{Node: node, Module: module, FP: w.FP}) + out = append(out, Want{Node: node, Module: module, FP: w.Fp}) } return out, nil } @@ -217,8 +220,8 @@ func KeepSet(builds []Build, wants []Want) map[string]*Ref { out := map[string]*Ref{} for _, b := range builds { for _, a := range b.Artifacts { - reference := catalogue.Recorded(a.Reference) - if _, ours := catalogue.InArtifactStore(reference); !ours || a.Kind == "package" { + reference := canonical(registryForms.Recorded(a.Reference)) + if _, ours := registryForms.InArtifactStore(reference); !ours || a.Kind == "package" { continue } r := out[reference] @@ -352,17 +355,17 @@ func (h *Holder) judge(ctx context.Context) (*judged, error) { // against is what each build the builder's log says ended stood on, by build key: the bases' references. func (h *Holder) against() (map[string][]string, error) { - entries, err := h.Bus.LogSince(delivery.BuildLogRead, h.now().Add(-delivery.BuilderLogDays*24*time.Hour)) + entries, err := h.Bus.LogSince(builds.BuildLogRead, h.now().Add(-builds.BuilderLogDays*24*time.Hour)) if err != nil { return nil, err } out := map[string][]string{} for _, raw := range entries { - e, err := delivery.DecodeBuildLogEntry(raw.Value) - if err != nil || e.Event != delivery.BuildEndedEvent { + e, err := builds.DecodeBuildLogEntry(raw.Value) + if err != nil || e.Event != builds.BuildEndedEvent { continue } - key, err := delivery.BuilderBuildKey(e.Module, e.FP) + key, err := builds.BuilderBuildKey(e.Module, e.FP) if err != nil { continue } @@ -383,7 +386,7 @@ func Mirrors(v *View, builds []Build, kept map[string][]string, against map[stri continue } for d := range v.L.Repos[name].Revisions { - r := catalogue.ArtifactStoreScheme + name + "@" + d + r := artifactref.ArtifactStoreScheme + name + "@" + d out[r] = &Ref{Reference: r, Kind: "image"} } } @@ -397,7 +400,7 @@ func Mirrors(v *View, builds []Build, kept map[string][]string, against map[stri continue } for _, base := range bases { - if m := out[catalogue.Recorded(base)]; m != nil { + if m := out[registryForms.Recorded(base)]; m != nil { m.Builds = append(m.Builds, b) why := kept[b.Key] if len(why) > 0 { @@ -445,7 +448,7 @@ func (j *judged) records(repository string, collected bool) *Records { } } r.Counts[state]++ - if (repository != "" && !strings.HasPrefix(strings.TrimPrefix(ref.Reference, catalogue.ArtifactStoreScheme), repository)) || + if (repository != "" && !strings.HasPrefix(strings.TrimPrefix(ref.Reference, artifactref.ArtifactStoreScheme), repository)) || (state == "collected" && !collected) { continue } @@ -511,13 +514,13 @@ func (h *Holder) Sweep(ctx context.Context, s Sweeping) (*CollectAnswer, error) return nil, err } a := &CollectAnswer{DryRun: s.DryRun, WouldLetGo: []string{}, LetGo: []string{}} - var keptArchives, keptImages, eligible []string + var keptArchives, eligible []string for _, r := range j.sorted() { switch { - case r.Kept && r.Kind == "archive": + case r.Kept && (r.Kind == "archive" || isCopy(r.Reference)): + // What retention would remove unless held: a bundle or an archive, and a copied base (ADR 0617 §2). keptArchives = append(keptArchives, r.Reference) case r.Kept: - keptImages = append(keptImages, r.Reference) default: if held, known := inStore(j.view, r.Reference); known && !held { continue // collected already @@ -543,33 +546,25 @@ func (h *Holder) Sweep(ctx context.Context, s Sweeping) (*CollectAnswer, error) a.Store = door.Address() within, stop := context.WithTimeout(ctx, SweepBudget) defer stop() - // Hold before letting go (ADR 0251 §8): a kept archive that cannot be held stops the sweep before any delete. + // Hold before letting go (ADR 0251 §8, ADR 0617 §2): each kept archive and copied base is held on its seat; one that + // cannot be held stops the sweep before any removal and writes the sweep's finding. var missing []string for _, r := range keptArchives { - wrote, err := door.Hold(within, r) - switch { - case err == nil: - if wrote { - a.HoldersWritten++ - } - case errors.Is(err, registry.Gone): + if held, known := inStore(j.view, r); known && !held { missing = append(missing, r) - case errors.Is(err, registry.ErrNotOurs): - default: + continue + } + if _, err := h.holdOnSeat(r, keptWhy(j.refs[r])); err != nil { a.Stopped = fmt.Sprintf("not every kept archive could be held, so nothing was let go: holding %s: %v", r, err) a.Left = len(eligible) + h.sweepFinding("the hold of "+r, err) return a, nil } + a.HoldersWritten++ } a.Held, a.Missing = len(keptArchives)-len(missing), len(missing) h.sayGone(j, missing, "a kept archive the store does not hold") - if s.Confirmed { - if err := door.SpareKept(within, keptImages, eligible); err != nil { - a.Stopped = fmt.Sprintf("which platform manifests a kept index names could not be read, so nothing was let go: %v", err) - a.Left = len(eligible) - return a, nil - } - } + refused := false for i, r := range eligible { if i >= s.Most || within.Err() != nil { a.Left = len(eligible) - i @@ -583,7 +578,7 @@ func (h *Holder) Sweep(ctx context.Context, s Sweeping) (*CollectAnswer, error) if !s.Confirmed { // An index waits for a confirmed collect (ADR 0251 §7, ADR 0257 §4). index, err := door.IsIndex(within, r) - if err != nil && !errors.Is(err, registry.Gone) && !errors.Is(err, registry.ErrNotOurs) { + if err != nil && !errors.Is(err, Gone) && !errors.Is(err, ErrNotOurs) { a.Stopped = fmt.Sprintf("the store could not say whether %s is an index, so nothing more was asked: %v", r, err) a.Left = len(eligible) - i break @@ -593,37 +588,35 @@ func (h *Holder) Sweep(ctx context.Context, s Sweeping) (*CollectAnswer, error) continue } } - err := door.LetGo(within, r) - switch { - case err == nil || errors.Is(err, registry.Gone): - case errors.Is(err, registry.ErrNotOurs): - a.Skipped++ - continue - default: - a.Stopped = fmt.Sprintf("the store refused to let go of %s, so nothing more was asked: %v", r, err) - a.Left = len(eligible) - i - } - if a.Stopped != "" { - break - } - // Recorded in the hand-act log, each one (ADR 0565 §10; the operator's "option A"). + // Removed through its seat (ADR 0617 §2), recorded in the hand-act log, each one (ADR 0565 §10; the operator's + // "option A"). why := s.Why if why == "" { why = "eligible: no want names it and it is not among its module's " + fmt.Sprint(KeptBuilds) + " newest builds" } - if _, err := h.Acts.Record(handacts.Request{What: "let go of " + r + " (" + s.By + ")", Why: why, + if err := h.removeOnSeat(r, why); err != nil { + a.Stopped = fmt.Sprintf("a registry seat refused to remove %s, so nothing more was asked: %v", r, err) + a.Left = len(eligible) - i + h.sweepFinding("the removal of "+r, err) + refused = true + break + } + if _, err := h.Acts.Record(meshhandactsclient.Request{What: "let go of " + r + " (" + s.By + ")", Why: why, Cause: "artifact-store-sweep", Verb: "collect"}); err != nil { h.logf("%s was let go of and could not be recorded in the hand-act log: %v", r, err) } a.LetGo = append(a.LetGo, r) } h.sayGone(j, a.LetGo, "let go of") + if !refused { + h.sweepClean() + } return a, nil } // sayGone says artifacts-gone for each build that named one of refs: the builder builds a key again whose artifact is gone. func (h *Holder) sayGone(j *judged, refs []string, how string) { - byKey := map[string]*delivery.ArtifactsGone{} + byKey := map[string]*ArtifactsGone{} var keys []string for _, r := range refs { ref := j.refs[r] @@ -633,16 +626,17 @@ func (h *Holder) sayGone(j *judged, refs []string, how string) { for _, b := range ref.Builds { g := byKey[b.Key] if g == nil { - g = &delivery.ArtifactsGone{Module: b.Module, Key: b.Key, Fingerprint: b.FP, At: h.now().UTC()} + at := h.now().UTC() + g = &ArtifactsGone{Module: b.Module, Key: b.Key, Fingerprint: b.FP, At: &at} byKey[b.Key] = g keys = append(keys, b.Key) } - g.Gone = append(g.Gone, r) + g.Gone = append(g.Gone, namedBy(b, r)) } } sort.Strings(keys) for _, k := range keys { - raw, err := delivery.EncodeArtifactsGone(*byKey[k]) + raw, err := EncodeArtifactsGone(*byKey[k]) if err == nil { err = h.Bus.Publish(GoneSubject, json.RawMessage(raw)) } @@ -652,6 +646,17 @@ func (h *Holder) sayGone(j *judged, refs []string, how string) { } } +// namedBy is the reference b names r by, in the form its key holds (the older one or the seat's): the builder matches +// artifacts-gone against its own key. +func namedBy(b Build, r string) string { + for _, a := range b.Artifacts { + if rec := registryForms.Recorded(a.Reference); canonical(rec) == r { + return rec + } + } + return r +} + // AfterBuild is the sweep at a build's end. func (h *Holder) AfterBuild(key string) { a, err := h.Sweep(context.Background(), Sweeping{Most: MostPerSweep, By: "after the build " + key}) @@ -665,30 +670,30 @@ func (h *Holder) AfterBuild(key string) { } // Held is the seat's `held`: each reference, held, gone or unknown with why. Holds and writes nothing. -func (h *Holder) Held(refs []string) (delivery.HeldAnswer, error) { +func (h *Holder) Held(refs []string) (HeldAnswer, error) { if len(refs) == 0 { - return delivery.HeldAnswer{}, errors.New("held judges the references given, and none was") + return HeldAnswer{}, errors.New("held judges the references given, and none was") } if len(refs) > 64 { - return delivery.HeldAnswer{}, fmt.Errorf("held judges at most 64 references at once, and %d were given", len(refs)) + return HeldAnswer{}, fmt.Errorf("held judges at most 64 references at once, and %d were given", len(refs)) } for _, r := range refs { - if _, ours := catalogue.InArtifactStore(r); !ours { - return delivery.HeldAnswer{}, fmt.Errorf("%s is not a reference into the mesh's artifact store (artifact-store://...): nothing was asked", r) + if _, ours := registryForms.InArtifactStore(r); !ours { + return HeldAnswer{}, fmt.Errorf("%s is not a reference into the mesh's artifact store (artifact-store://...): nothing was asked", r) } } door, err := h.Door() if err != nil { - return delivery.HeldAnswer{}, err + return HeldAnswer{}, err } asking, stop := context.WithTimeout(context.Background(), 20*time.Second) defer stop() - a := delivery.HeldAnswer{Store: door.Address(), Held: []string{}, Gone: []string{}} + a := HeldAnswer{Store: door.Address(), Held: []string{}, Gone: []string{}} for _, r := range refs { - switch err := door.Holds(asking, r); { + switch err := door.Holds(asking, canonical(r)); { case err == nil: a.Held = append(a.Held, r) - case errors.Is(err, registry.Gone): + case errors.Is(err, Gone): a.Gone = append(a.Gone, r) default: if a.Unknown == nil { @@ -724,7 +729,7 @@ func (h *Holder) ImagesOf(node string, address string) (Images, error) { } byImage := map[string]*Image{} add := func(module, fp, in string) error { - key, err := delivery.BuilderBuildKey(module, fp) + key, err := builds.BuilderBuildKey(module, fp) if err != nil { return nil } @@ -732,14 +737,14 @@ func (h *Holder) ImagesOf(node string, address string) (Images, error) { if err != nil || !found { return err } - b, err := delivery.DecodeBuilderBuild(raw) + b, err := builds.DecodeBuilderBuild(raw) if err != nil { return nil } images := []string{} for _, a := range b.Artifacts { if (a.Kind == "image" || a.Kind == "upstream") && a.Reference != "" { - images = append(images, catalogue.Routed(catalogue.Recorded(a.Reference), address)) + images = append(images, registryForms.Routed(registryForms.Recorded(a.Reference), address)) } } pinned, err := h.Pinned(b.Manifest) @@ -773,7 +778,7 @@ func (h *Holder) ImagesOf(node string, address string) (Images, error) { return out, fmt.Errorf("the haves: %w", err) } for _, k := range keys { - n, module, isNode, err := delivery.ParseHaveKey(k) + n, module, isNode, err := engineclient.ParseHaveKey(k) if err != nil || isNode || n != node { continue } @@ -781,12 +786,12 @@ func (h *Holder) ImagesOf(node string, address string) (Images, error) { if err != nil { return out, err } - var have delivery.Have - if !found || json.Unmarshal(raw, &have) != nil || have.FP == "" { + var have nodeengine.HaveByNodeModuleValue + if !found || json.Unmarshal(raw, &have) != nil || have.Fp == "" { continue } out.SentKnown = true - if err := add(module, have.FP, "sent"); err != nil { + if err := add(module, have.Fp, "sent"); err != nil { return out, err } } @@ -805,7 +810,7 @@ func (h *Holder) Pinned(manifest string) ([]string, error) { if manifest == "" { return nil, nil } - key, err := delivery.ManifestsKey(manifest) + key, err := catalogclient.ManifestsKey(manifest) if err != nil { return nil, nil } @@ -813,14 +818,14 @@ func (h *Holder) Pinned(manifest string) ([]string, error) { if err != nil || !found { return nil, err } - m, err := delivery.DecodeManifest(raw) + m, err := catalogclient.DecodeManifest(raw) if err != nil { return nil, nil } var parsed struct { Resources []map[string]any `json:"resources"` } - if json.Unmarshal([]byte(m.JSON), &parsed) != nil { + if json.Unmarshal([]byte(m.Manifest), &parsed) != nil { return nil, nil } var out []string @@ -840,8 +845,8 @@ func inStoreAt(image, address string) string { if address == "" || !strings.HasPrefix(image, address+"/") || !strings.Contains(image, "@sha256:") { return "" } - r := catalogue.Recorded(image) - if _, ours := catalogue.InArtifactStore(r); !ours { + r := registryForms.Recorded(image) + if _, ours := registryForms.InArtifactStore(r); !ours { return "" } return r @@ -865,7 +870,7 @@ func (h *Holder) Observe(ctx context.Context) error { for _, b := range j.built { builds[b.Module+" "+b.FP] = b } - now := map[string]health.Finding{} + now := map[string]meshconditions.FindingsByKeyValue{} for _, w := range j.wants { b, ok := builds[w.Module+" "+w.FP] if !ok { @@ -874,7 +879,7 @@ func (h *Holder) Observe(ctx context.Context) error { var refs []string for _, a := range b.Artifacts { if a.Kind == "image" { - refs = append(refs, catalogue.Recorded(a.Reference)) + refs = append(refs, registryForms.Recorded(a.Reference)) } } // And what its manifest pins by itself under the store's address, as the controller judged S25. @@ -899,7 +904,16 @@ func (h *Holder) Observe(ctx context.Context) error { return fmt.Errorf("the findings: %w", err) } for _, k := range keys { + if k == sweepFindingKey { + continue // the sweep's own finding, which the sweep clears + } if _, still := now[k]; !still { + // A finding of a first view unanswered is the reader's (views.Reader), cleared by its answer, not by a sweep. + if raw, found, err := h.Bus.Get(FindingsState, k); err == nil && found { + if f, err := meshconditions.DecodeFindingsByKeyValue(raw); err == nil && f.Kind == views.UnansweredKind { + continue + } + } if err := h.Bus.Delete(FindingsState, k); err != nil { return err } @@ -911,11 +925,11 @@ func (h *Holder) Observe(ctx context.Context) error { return err } if found { - if was, err := health.DecodeFinding(raw); err == nil && was.Summary == f.Summary { + if was, err := meshconditions.DecodeFindingsByKeyValue(raw); err == nil && was.Summary == f.Summary { continue // written only when what it says changes } } - if _, err := health.EncodeFinding(f); err != nil { + if err := meshconditionsclient.CheckFinding(f); err != nil { return err } if err := h.Bus.Put(FindingsState, k, f); err != nil { @@ -926,11 +940,11 @@ func (h *Holder) Observe(ctx context.Context) error { } // imageMissing is S25's finding, in the controller's words (cmd/mesh-controller/image_missing.go watchMissingImages). -func imageMissing(node, module, image string, at time.Time) health.Finding { +func imageMissing(node, module, image string, at time.Time) meshconditions.FindingsByKeyValue { id := module + "." + node - return health.Finding{V: health.FindingVersion, Key: health.FindingKey(health.ScopeModule, id, ImageMissing), Kind: ImageMissing, - Subject: health.Subject{Scope: health.ScopeModule, ID: id, Machine: node}, - Severity: health.Warning, Resolver: health.ResolverOperator, + return meshconditions.FindingsByKeyValue{V: meshconditionsclient.FindingVersion, Key: meshconditionsclient.Key(meshconditionsclient.ScopeModule, id, ImageMissing), Kind: ImageMissing, + Subject: meshconditions.Subject{Scope: meshconditionsclient.ScopeModule, ID: id, Machine: node}, + Severity: meshconditionsclient.Warning, Resolver: meshconditionsclient.ResolverOperator, Summary: fmt.Sprintf("%s is left out of every send to %s: its declaration names an image that the artifact store does "+ "not hold (the evidence names it); the running container is untouched", module, node), Headline: capital(module) + " is not updated on " + node, @@ -938,7 +952,7 @@ func imageMissing(node, module, image string, at time.Time) health.Finding { "was not sent to %s. What runs there is untouched, and the rest of %s is sent as usual.", module, node, node), Needs: fmt.Sprintf("build %s again; the details name the image.", module), Resolved: "Resolved: the store holds the image of " + module + " again", - Evidence: []health.Evidence{{At: at.UTC(), Said: "image " + image + " is not in the store"}}, + Evidence: []meshconditions.Evidence{{At: at.UTC(), Said: "image " + image + " is not in the store"}}, Source: "S25", Observer: Module, At: at.UTC()} } diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/holder_integration_test.go b/modules/artifact-store-tools/cmd/artifact-store-tools/holder_integration_test.go index b97cfd9a7..575dfcc00 100644 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/holder_integration_test.go +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/holder_integration_test.go @@ -19,8 +19,9 @@ import ( "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" + meshhandacts "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/handacts" "git.novox.be/novox/mesh-sdk/go/testbus" ) @@ -158,7 +159,7 @@ func TestTheSeatSweepsAtABuildsEndOnARealBusWithNoLease(t *testing.T) { // The seat mesh-hand-acts, answering every act it records; artifacts-gone heard on the seat's subject. var mu sync.Mutex var acts, gone []string - if _, err := nc.Subscribe("mesh.seat."+handacts.Seat+".tool.hand-act", func(m *nats.Msg) { + if _, err := nc.Subscribe("mesh.seat."+meshhandacts.Seat+".tool.hand-act", func(m *nats.Msg) { mu.Lock() acts = append(acts, string(m.Data)) mu.Unlock() @@ -173,15 +174,32 @@ func TestTheSeatSweepsAtABuildsEndOnARealBusWithNoLease(t *testing.T) { }); err != nil { t.Fatal(err) } - h := &Holder{Bus: findingsAt{realBus: bus}, Door: mem.Door, Listed: mem.Listed, Now: time.Now, Logf: t.Logf} - h.Acts = handacts.Recorder{Ask: func(key string, body any) (json.RawMessage, error) { + // The registry seats' holder on the bus (CAT-AS1): the sweep holds and removes through their verbs. + for _, seat := range []string{"oci-artifact-registry", "container-image-registry"} { + if _, err := nc.Subscribe("mesh.seat."+seat+".tool.*", func(m *nats.Msg) { + var body map[string]any + _ = json.Unmarshal(m.Data, &body) + mu.Lock() + out, err := door.seat("seat:"+seat+"."+m.Subject[strings.LastIndex(m.Subject, ".")+1:], body) + mu.Unlock() + if err != nil { + out, _ = json.Marshal(map[string]string{"error": err.Error()}) + } + _ = m.Respond(out) + }); err != nil { + t.Fatal(err) + } + } + ask := func(key string, body any) (json.RawMessage, error) { raw, _ := json.Marshal(body) m, err := nc.Request("mesh.seat."+strings.TrimPrefix(strings.Replace(key, ".", ".tool.", 1), "seat:"), raw, 5*time.Second) if err != nil { return nil, err } return m.Data, nil - }} + } + h := &Holder{Bus: findingsAt{realBus: bus}, Door: mem.Door, Listed: mem.Listed, Seats: ask, Now: time.Now, Logf: t.Logf} + h.Acts = meshhandactsclient.Recorder{Ask: ask} // A build's end heard from the builds bucket, as follow hands it over. kv, _ := bus.kv(BuildsRead) diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/holder_test.go b/modules/artifact-store-tools/cmd/artifact-store-tools/holder_test.go index eac183dba..6aa10a50f 100644 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/holder_test.go +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/holder_test.go @@ -3,19 +3,28 @@ package main import ( "context" "encoding/json" + "errors" "fmt" + nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + meshwants "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" + builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "slices" "sort" "strings" "sync" "testing" "time" + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" + meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/handacts" - "git.novox.be/novox/mesh-sdk/go/health" - "artifactstoretools/internal/registry" + artifactref "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient/reference" + reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + ociseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry" ) // memBus is the holder's bus in memory: states by name, each key's history, and what was published. @@ -30,7 +39,7 @@ type memBus struct { func (b *memBus) LogSince(read string, at time.Time) ([]stdio.LogEntry, error) { var out []stdio.LogEntry for _, e := range b.log { - if read == delivery.BuildLogRead && !e.At.Before(at) { + if read == builds.BuildLogRead && !e.At.Before(at) { out = append(out, e) } } @@ -85,37 +94,63 @@ func (b *memBus) Publish(subject string, body any) error { return nil } -// memDoor is the store's door in memory: what it holds, which references are indexes, and what was asked. +// memDoor is the store's door in memory, read only, and the registry seats' holder: what it holds, which references are +// indexes, what the seats were asked (asked: "hold ", "let go "; calls: each seat call as its key and name), the +// holds asked (held), and the calls it refuses (refuse: by " "). type memDoor struct { holds map[string]bool indexes map[string]bool asked []string + calls []string + held map[string]bool + refuse map[string]error +} + +// seat is the registry seats' holder, as Holder.Seats calls it. +func (d *memDoor) seat(key string, body any) (json.RawMessage, error) { + raw, _ := json.Marshal(body) + var in struct{ Name, Digest, Reason string } + _ = json.Unmarshal(raw, &in) + seat, verb, _ := strings.Cut(strings.TrimPrefix(key, "seat:"), ".") + ref := artifactref.ArtifactStoreScheme + in.Name + "@" + in.Digest + if seat == "oci-artifact-registry" { + ref = artifactref.ArtifactStoreScheme + in.Name + "/blobs/" + in.Digest + } + d.calls = append(d.calls, key+" "+in.Name+"@"+in.Digest+" "+in.Reason) + if err := d.refuse[verb+" "+ref]; err != nil { + return nil, err + } + if d.held == nil { + d.held = map[string]bool{} + } + switch verb { + case "hold": + d.asked = append(d.asked, "hold "+ref) + d.held[ref] = true + return json.Marshal(map[string]any{"held": true}) + case "release": + released := d.held[ref] // what is not held answers released false, as the seat's definition says + delete(d.held, ref) + return json.Marshal(map[string]any{"released": released}) + case "remove": + if d.held[ref] { + return nil, fmt.Errorf("remove: refused: %s@%s is held; release it first", in.Name, in.Digest) + } + d.asked = append(d.asked, "let go "+ref) + delete(d.holds, ref) + return json.Marshal(map[string]any{"removed": true}) + } + return nil, fmt.Errorf("no verb %s", key) } func (d *memDoor) Address() string { return "10.0.0.5:5000" } -func (d *memDoor) Hold(_ context.Context, r string) (bool, error) { - d.asked = append(d.asked, "hold "+r) - if !d.holds[r] { - return false, registry.Gone - } - return false, nil -} func (d *memDoor) Holds(_ context.Context, r string) error { if !d.holds[r] { - return registry.Gone + return Gone } return nil } func (d *memDoor) IsIndex(_ context.Context, r string) (bool, error) { return d.indexes[r], nil } -func (d *memDoor) LetGo(_ context.Context, r string) error { - d.asked = append(d.asked, "let go "+r) - delete(d.holds, r) - return nil -} -func (d *memDoor) SpareKept(context.Context, []string, []string) error { - d.asked = append(d.asked, "spare") - return nil -} // mirror is a copy of an upstream base in the store, the n-th. func mirror(n int) string { @@ -146,8 +181,8 @@ func aMesh(t *testing.T) (*Holder, *memBus, *memDoor, *[]string) { door := &memDoor{holds: map[string]bool{}, indexes: map[string]bool{}} listing := &Listing{Blobs: map[string]int64{}, Repos: map[string]*Repo{}} for n := 1; n <= 7; n++ { - key, _ := delivery.BuilderBuildKey("letta", fp(n)) - raw, err := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: strings.Repeat("d", 64), Artifacts: []delivery.BuiltArtifact{ + key, _ := builds.BuilderBuildKey("letta", fp(n)) + raw, err := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, Manifest: strings.Repeat("d", 64), Artifacts: []builds.BuiltArtifact{ {Name: "image", Kind: "image", Digest: digestOf(imageRef("letta", n)), Reference: imageRef("letta", n)}, {Name: "files", Kind: "archive", Digest: digestOf(archiveRef("letta", n)), Reference: archiveRef("letta", n)}, {Name: "pkg", Kind: "package", Digest: "sha256:" + strings.Repeat("f", 64), Reference: "letta@1.0." + fmt.Sprint(n)}}}) @@ -175,7 +210,7 @@ func aMesh(t *testing.T) (*Holder, *memBus, *memDoor, *[]string) { door.holds[mirror(n)] = true } for n := 1; n <= 7; n++ { - e := delivery.BuildLogEntry{Event: delivery.BuildEndedEvent, Module: "letta", FP: fp(n), Repository: "novox/x", Commit: "c", + e := builds.BuildLogEntry{Event: builds.BuildEndedEvent, Module: "letta", FP: fp(n), Repository: "novox/x", Commit: "c", Node: "ace", At: day.Add(time.Duration(n) * time.Hour), Outcome: "done"} switch n { case 2: @@ -183,21 +218,21 @@ func aMesh(t *testing.T) (*Holder, *memBus, *memDoor, *[]string) { case 7: e.Against = []string{mirror(2)} } - raw, _ := delivery.EncodeBuildLogEntry(e) + raw, _ := builds.EncodeBuildLogEntry(e) b.log = append(b.log, stdio.LogEntry{Sequence: uint64(n), Key: "letta", Value: raw, At: e.At}) } - manifest, _ := delivery.EncodeManifest(delivery.Manifest{Module: "letta", - JSON: `{"module":"letta","resources":[{"id":"app","type":"container","image":"` + pinnedImage + `"},` + + manifest, _ := catalogclient.EncodeManifest(catalog.ManifestsByDigestValue{Module: "letta", + Manifest: `{"module":"letta","resources":[{"id":"app","type":"container","image":"` + pinnedImage + `"},` + `{"id":"built","type":"container","image":"${artifact:image}"}]}`}) - mk, _ := delivery.ManifestsKey(strings.Repeat("d", 64)) + mk, _ := catalogclient.ManifestsKey(strings.Repeat("d", 64)) _ = b.Put(ManifestsRead, mk, json.RawMessage(manifest)) - want, _ := json.Marshal(delivery.Want{V: delivery.Version, FP: fp(1), Self: true}) + want, _ := json.Marshal(meshwants.WantsByWantNodeModuleValue{V: 1, Fp: fp(1), Self: true}) _ = b.Put(WantsRead, "want.ace.letta", json.RawMessage(want)) var acts []string - h := &Holder{Bus: b, Door: func() (Door, error) { return door, nil }, + h := &Holder{Bus: b, Door: func() (Door, error) { return door, nil }, Seats: door.seat, Listed: func(context.Context) (*View, error) { return &View{L: listing}, nil }, Now: func() time.Time { return day.Add(48 * time.Hour) }, - Acts: handacts.Recorder{Ask: func(key string, body any) (json.RawMessage, error) { + Acts: meshhandactsclient.Recorder{Ask: func(key string, body any) (json.RawMessage, error) { raw, _ := json.Marshal(body) acts = append(acts, key+" "+string(raw)) return json.RawMessage(`{}`), nil @@ -246,18 +281,43 @@ func TestTheSeatKeepsTheFiveNewestAndTheWantedAndSweepsWithNoLease(t *testing.T) left int skipped int unlog int + // held is how many kept archives and copies are held (7 when 0); seatForm names letta's 2nd and 3rd builds in the + // seat's form; refuse is the seat call refused, " ". + held int + seatForm bool + refuse string }{ {name: "a build's end", how: "build", letGo: all}, + // ADR 0605 §9: a build that names its artifacts in the seat's form is the same artifact: removed and held under the + // seat's name and digest, and said gone in its own form. + {name: "the seat's form", most: MostPerSweep, letGo: all, seatForm: true}, + // A seat that refuses a removal stops the sweep; the finding sweep-refused is written, and cleared by a clean run. + {name: "a refused removal", most: MostPerSweep, letGo: []string{archiveRef("letta", 2)}, left: 2, refuse: "remove " + imageRef("letta", 2)}, {name: "a confirmed collect", how: "collect", letGo: all}, {name: "a sweep", most: MostPerSweep, letGo: all}, {name: "an index waits", index: true, most: MostPerSweep, letGo: []string{archiveRef("letta", 2), mirror(1)}, skipped: 1}, {name: "bounded", most: 1, letGo: []string{archiveRef("letta", 2)}, left: 2}, // A kept build the log no longer holds: what it stood on is unknown, so no copy goes. - {name: "a kept build's bases unknown", most: MostPerSweep, letGo: []string{archiveRef("letta", 2), imageRef("letta", 2)}, unlog: 3}, + {name: "a kept build's bases unknown", most: MostPerSweep, letGo: []string{archiveRef("letta", 2), imageRef("letta", 2)}, unlog: 3, held: 8}, } { t.Run(c.name, func(t *testing.T) { h, b, door, acts := aMesh(t) door.indexes[imageRef("letta", 2)] = c.index + if c.held == 0 { + c.held = 7 + } + if c.seatForm { + for _, n := range []int{2, 3} { + key, _ := builds.BuilderBuildKey("letta", fp(n)) + raw, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, Manifest: strings.Repeat("d", 64), Artifacts: []builds.BuiltArtifact{ + {Name: "image", Kind: "image", Digest: digestOf(imageRef("letta", n)), Reference: artifactref.SeatReference(reg.Seat, "letta", "image", digestOf(imageRef("letta", n)))}, + {Name: "files", Kind: "archive", Digest: digestOf(archiveRef("letta", n)), Reference: artifactref.SeatReference(ociseat.Seat, "letta", "files", digestOf(archiveRef("letta", n)))}}}) + _ = b.Put(BuildsRead, key, json.RawMessage(raw)) + } + } + if c.refuse != "" { + door.refuse = map[string]error{c.refuse: errors.New("the store is read only")} + } if c.unlog > 0 { b.log = append(b.log[:c.unlog-1:c.unlog-1], b.log[c.unlog:]...) } @@ -284,7 +344,7 @@ func TestTheSeatKeepsTheFiveNewestAndTheWantedAndSweepsWithNoLease(t *testing.T) if err != nil { t.Fatal(err) } - if fmt.Sprint(a.LetGo) != fmt.Sprint(c.letGo) || a.Left != c.left || a.Skipped != c.skipped || a.Held != 6 || a.Missing != 0 { + if fmt.Sprint(a.LetGo) != fmt.Sprint(c.letGo) || a.Left != c.left || a.Skipped != c.skipped || a.Held != c.held || a.Missing != 0 { t.Fatalf("let go of %v (left %d, skipped %d, held %d, missing %d), want %v", a.LetGo, a.Left, a.Skipped, a.Held, a.Missing, c.letGo) } } @@ -298,8 +358,34 @@ func TestTheSeatKeepsTheFiveNewestAndTheWantedAndSweepsWithNoLease(t *testing.T) held++ } } - if held != 6 { - t.Errorf("the store was asked %v: the six kept archives are held first", door.asked) + if held != c.held { + t.Errorf("the seats were asked %v: the %d kept archives and copies are held first", door.asked, c.held) + } + // Each removal asks the seat that holds it, by its name and digest, for retention, after a release. + for _, call := range door.calls { + if strings.Contains(call, ".remove ") && !strings.HasSuffix(call, " "+Retention) { + t.Errorf("a removal asked %q, not for retention", call) + } + } + if c.letGo[0] == archiveRef("letta", 2) && !slices.Contains(door.calls, "seat:oci-artifact-registry.remove letta/files@"+digestOf(archiveRef("letta", 2))+" "+Retention) { + t.Errorf("the seats were called %v", door.calls) + } + _, finding, _ := b.Get(FindingsState, sweepFindingKey) + if finding != (c.refuse != "") { + t.Errorf("the sweep's finding is written: %v, want %v", finding, c.refuse != "") + } + if c.refuse != "" { + door.refuse = nil + if _, err := h.Sweep(context.Background(), Sweeping{Most: MostPerSweep, By: "again"}); err != nil { + t.Fatal(err) + } + if _, still, _ := b.Get(FindingsState, sweepFindingKey); still { + t.Error("a clean sweep left the sweep's finding") + } + return + } + if c.seatForm && !strings.Contains(b.published[0], `"container-image-registry/letta/image@`+digestOf(imageRef("letta", 2))+`"`) { + t.Errorf("artifacts-gone does not name the build's own form: %v", b.published) } if len(*acts) != len(c.letGo) || !strings.HasPrefix((*acts)[0], "seat:mesh-hand-acts.hand-act ") || !strings.Contains((*acts)[0], c.letGo[0]) { @@ -324,9 +410,9 @@ func TestAWantedImageTheStoreLacksIsAFindingAndImagesNamesWantsAndHaves(t *testi if err := h.Observe(context.Background()); err != nil { t.Fatal(err) } - key := health.FindingKey(health.ScopeModule, "letta.ace", ImageMissing) + key := meshconditionsclient.Key(meshconditionsclient.ScopeModule, "letta.ace", ImageMissing) raw, found, _ := b.Get(FindingsState, key) - f, err := health.DecodeFinding(raw) + f, err := meshconditions.DecodeFindingsByKeyValue(raw) if !found || err != nil || f.Subject.Machine != "ace" || f.Source != "S25" || !strings.Contains(f.Evidence[0].Said, imageRef("letta", 1)) { t.Fatalf("the finding is %+v (%v, %v)", f, found, err) } @@ -346,7 +432,7 @@ func TestAWantedImageTheStoreLacksIsAFindingAndImagesNamesWantsAndHaves(t *testi t.Fatalf("the pinnedImage image missing is %s", raw) } - have, _ := json.Marshal(delivery.Have{V: delivery.Version, FP: fp(7), Phase: "running"}) + have, _ := json.Marshal(nodeengine.HaveByNodeModuleValue{V: 1, Fp: fp(7), Phase: "running"}) _ = b.Put(HavesRead, "ace.letta", json.RawMessage(have)) images, err := h.ImagesOf("ace", "10.0.0.5:5000") if err != nil || len(images.Images) != 3 || !images.SentKnown || images.Images[0].Image != "10.0.0.5:5000/letta/image@sha256:"+strings.Repeat("1", 64) || diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/listed.go b/modules/artifact-store-tools/cmd/artifact-store-tools/listed.go new file mode 100644 index 000000000..062427320 --- /dev/null +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/listed.go @@ -0,0 +1,166 @@ +package main + +// **The store, read through its seats** (novox/hq ADR 0632 §4): the read tools and the sweep read what the store holds +// from the seats' `list`, container-image-registry's images and indexes and oci-artifact-registry's bundles and archives, +// never from the store's files or its read side. What a manifest marks beyond itself is the seat's size of it, so a +// blob two manifests share is counted in each, and what the store's nightly collector frees is not known before it runs. + +import ( + "context" + "encoding/json" + "fmt" + "sort" + + ociseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry" + + reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" +) + +// Repo is one repository as the seats' list answers it. +type Repo struct { + Name string + // Revisions are the digests of every manifest it holds, tagged or not. + Revisions map[string]bool + // Tags are what each tag names now. + Tags map[string]string +} + +// Listing is the store as the seats' list answers it: every repository, and a size for each manifest and each +// bundle's or archive's blob, keyed by its digest. A manifest's size is its own content and every blob it names, as the +// seat answers it, so a layer two manifests share is counted in each. +type Listing struct { + Blobs map[string]int64 + Repos map[string]*Repo +} + +func (l *Listing) repo(name string) *Repo { + r := l.Repos[name] + if r == nil { + r = &Repo{Name: name, Revisions: map[string]bool{}, Tags: map[string]string{}} + l.Repos[name] = r + } + return r +} + +// RepoNames are the repositories in order. +func (l *Listing) RepoNames() []string { + out := make([]string, 0, len(l.Repos)) + for n := range l.Repos { + out = append(out, n) + } + sort.Strings(out) + return out +} + +// Manifest is what one manifest names. +type Manifest struct { + MediaType string + ConfigMedia string + // Refs are the blobs it names: its configuration and its layers. + Refs []string + // Layers are its layers alone, in order. + Layers []string + // Children are the manifests an index names. + Children []string +} + +// Holder is the shape of a manifest that keeps one blob: an empty configuration and one layer. The +// controller holds every archive the mesh keeps this way (novox/hq issue 253), and keeps a named +// document the same way, under a tag. +func (m *Manifest) Holder() bool { + return m != nil && m.ConfigMedia == "application/vnd.oci.empty.v1+json" && len(m.Layers) == 1 +} + +// Key names one manifest in one repository. +func Key(repo, digest string) string { return repo + "@" + digest } + +// Store is the artifact store as its two seats' `list` answers it: Ask is the runtime's stdio.Ask in main. +type Store struct { + Ask func(key string, body any) (json.RawMessage, error) +} + +// ListOf asks seat's list, of one repository or, with name empty, of every one. +func (s *Store) ListOf(seat, name string) ([]reg.Repository, error) { + if s == nil || s.Ask == nil { + return nil, fmt.Errorf("this process reaches no registry seat") + } + raw, err := s.Ask("seat:"+seat+"."+reg.VerbList, reg.ListInput{Name: name}) + if err != nil { + return nil, fmt.Errorf("%s's list: %w", seat, err) + } + var a reg.ListAnswer + if err := json.Unmarshal(raw, &a); err != nil { + return nil, fmt.Errorf("%s's answer to list: %w", seat, err) + } + return a.Repositories, nil +} + +// View reads the store now, through both seats. +func (s *Store) View(context.Context) (*View, error) { + images, err := s.ListOf(reg.Seat, "") + if err != nil { + return nil, err + } + artifacts, err := s.ListOf(ociseat.Seat, "") + if err != nil { + return nil, err + } + return ViewOf(images, artifacts), nil +} + +// ViewOf is the store as the two seats list it: each manifest a repository's revision, with its tags; an index naming its +// platform manifests; a bundle's or archive's manifest keeping its one blob, so it reads as the holder of that archive. +func ViewOf(seats ...[]reg.Repository) *View { + l := &Listing{Blobs: map[string]int64{}, Repos: map[string]*Repo{}} + m := map[string]*Manifest{} + held := map[string]bool{} + for _, repos := range seats { + for _, r := range repos { + repo := l.repo(r.Name) + for _, e := range r.Manifests { + repo.Revisions[e.Digest] = true + for _, t := range e.Tags { + repo.Tags[t] = e.Digest + } + doc := &Manifest{Children: append([]string(nil), e.Platforms...)} + l.Blobs[e.Digest] = e.Size + if e.Artifact != "" { + // The holder's size is the archive's with it: the blob itself counts nothing more. + doc.ConfigMedia, doc.Layers, doc.Refs = emptyConfig, []string{e.Artifact}, []string{e.Artifact} + if _, sized := l.Blobs[e.Artifact]; !sized { + l.Blobs[e.Artifact] = 0 + } + } + m[Key(r.Name, e.Digest)] = doc + if e.Held { + held[Key(r.Name, e.Digest)] = true + } + } + } + } + return &View{L: l, M: m, Unread: map[string]string{}, Held: held} +} + +// emptyConfig is the configuration media type of a manifest keeping one blob. +const emptyConfig = "application/vnd.oci.empty.v1+json" + +// listed is the one entry of a reference's seat list naming it: by its manifest's digest, or a bundle's or an archive's by +// the blob its reference names. Gone when the seat lists none. +func (s *Store) listed(reference string) (reg.Listed, error) { + seat, name, digest, err := seatOf(reference) + if err != nil { + return reg.Listed{}, fmt.Errorf("%w: %v", ErrNotOurs, err) + } + repos, err := s.ListOf(seat, name) + if err != nil { + return reg.Listed{}, err + } + for _, r := range repos { + for _, e := range r.Manifests { + if (seat == ociseat.Seat && e.Artifact == digest) || (seat != ociseat.Seat && e.Digest == digest) { + return e, nil + } + } + } + return reg.Listed{}, Gone +} diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/main.go b/modules/artifact-store-tools/cmd/artifact-store-tools/main.go index 8eb92fba7..3af7eacc5 100644 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/main.go +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/main.go @@ -1,38 +1,46 @@ // The artifact-store-tools module's Go bundle (novox/hq ADR 0251 §1–3, ADR 0571 clause 2, to-be 51): a process the // node's runtime launches and speaks MCP over stdio to. It says what the artifact store holds — its repositories, its -// size, and what the mesh keeps of each manifest — read from the store's own files through the store's own container, -// and it holds the seat mesh-artifact-sweep: the keep set, the sweep at each build's end, and S25's image-missing. +// size, and what the mesh keeps of each manifest — read through the registry seats' list (novox/hq ADR 0632 §4), and it +// holds the seat mesh-artifact-sweep: the keep set, the sweep at each build's end, and S25's image-missing. // -// **A module beside the store, not the store's own.** A module that provides the artifact store cannot also build an -// artifact: building publishes to the store, so the store would be needed to create itself. So these tools are a -// second module, assigned to the machine that holds the store. stdout is the protocol; what this bundle says, it says -// on stderr. +// **A module beside the store, not the store's own.** It requires no provision: what it reads, holds and removes it asks +// the registry seats (listed.go, sweepseats.go), never the store's files or its read side. stdout is the protocol; what this bundle says, it says on stderr. package main import ( + "context" "fmt" "os" "time" + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/handacts" + + "git.novox.be/novox/mesh-sdk/go/views" ) func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) } func main() { - s := &Store{ - Container: os.Getenv("MESH_ARTIFACT_STORE_CONTAINER"), - Run: ExecRunner, - UID: os.Getuid(), - } - h := &Holder{Bus: runtimeBus{}, Door: DoorAt(os.Getenv("MESH_ARTIFACT_STORE_ADDRESS_FILE")), Listed: s.View, - Now: time.Now, Logf: logf} - h.Acts = handacts.Recorder{Ask: stdio.Ask} - go follow(h) + s := &Store{Ask: stdio.Ask} + v := &views.Reader{Module: "artifact-store-tools", Logf: logf, Findings: meshconditionsclient.FindingWriter{State: stdio.State("findings")}, Acts: meshhandactsclient.Recorder{Ask: stdio.Ask}} + v.Follow(assignerModule, "wants", WantsTool) + v.Follow(indexerModule, "manifests", ManifestsTool) + h := &Holder{Bus: runtimeBus{views: v}, Door: DoorAt(os.Getenv("MESH_ARTIFACT_STORE_ADDRESS_FILE"), s), Listed: s.View, + Seats: stdio.Ask, Now: time.Now, Logf: logf} + h.Acts = meshhandactsclient.Recorder{Ask: stdio.Ask} + v.Start(context.Background()) + go follow(h, v) // An empty name serves as the module the runtime names (MESH_SERVED_MODULE): artifact-store-tools. - if err := stdio.Serve("", append(Tools(s, h, &Catalogue{Ask: stdio.Ask}), SeatTools(h, s)...)); err != nil { + if err := stdio.Serve("", append(append(Tools(s, h, &Catalogue{Ask: stdio.Ask}), SeatTools(h, s)...), v.Tools("artifact_store")...)); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } + +// The owners' read tools of the states the holder follows, which the manifest invokes (ADR 0615). +const ( + WantsTool = assignerModule + ".assigner_read_wants" + ManifestsTool = indexerModule + ".indexer_read_manifests" +) diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/read.go b/modules/artifact-store-tools/cmd/artifact-store-tools/read.go deleted file mode 100644 index d3b8f2ad9..000000000 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/read.go +++ /dev/null @@ -1,175 +0,0 @@ -package main - -import ( - "context" - "fmt" - "sort" - "strings" - "sync" -) - -// readScript prints each manifest named on its command line, each after a line naming its path. A -// manifest is JSON, which holds no raw line break inside a string, so no line of one starts with the -// marker. One the store does not have is said, not skipped. -const readScript = `cd ` + storageRoot + ` -for p in "$@"; do - echo "#manifest $p" - if [ -f "$p" ]; then cat "$p"; echo; else echo "#absent"; fi -done -echo '#end'` - -// readBatch is how many manifests one exec reads: well under any command line's limit. -const readBatch = 400 - -// Store reaches the artifact store's own files, through its own container, and only reads them. -type Store struct { - Container string - Run Runner - UID int - - mu sync.Mutex - // cache holds every manifest read: a manifest is named by its content's digest, so it never changes. - cache map[string]*Manifest -} - -// List reads the store's files. -func (s *Store) List(ctx context.Context) (*Listing, error) { - if s.Container == "" { - return nil, fmt.Errorf("this bundle was not told the artifact store's container (MESH_ARTIFACT_STORE_CONTAINER)") - } - out, err := docker(ctx, s.Run, s.UID, "exec", s.Container, "sh", "-c", listScript) - if err != nil { - return nil, err - } - return ParseListing(out) -} - -// Key names one manifest in one repository. -func Key(repo, digest string) string { return repo + "@" + digest } - -// blobPath is where the store keeps a blob, under storageRoot. -func blobPath(digest string) string { - algorithm, hex, _ := strings.Cut(digest, ":") - if algorithm == "" || len(hex) < 2 || strings.ContainsAny(digest, "/ \n") { - return "" - } - return "blobs/" + algorithm + "/" + hex[:2] + "/" + hex + "/data" -} - -// Manifests reads every manifest the listing names, from the cache or the store's files. Answers each -// one read, and each one not read with why. -func (s *Store) Manifests(ctx context.Context, l *Listing) (map[string]*Manifest, map[string]string) { - need := map[string]bool{} - s.mu.Lock() - if s.cache == nil { - s.cache = map[string]*Manifest{} - } - for _, name := range l.RepoNames() { - for d := range l.Repos[name].Revisions { - if _, ok := s.cache[d]; !ok { - need[d] = true - } - } - } - s.mu.Unlock() - - digests := make([]string, 0, len(need)) - for d := range need { - digests = append(digests, d) - } - sort.Strings(digests) - failed := map[string]string{} - for start := 0; start < len(digests); start += readBatch { - batch := digests[start:min(start+readBatch, len(digests))] - read, err := s.read(ctx, batch) - s.mu.Lock() - for _, d := range batch { - switch m := read[d]; { - case err != nil: - failed[d] = err.Error() - case m == nil: - failed[d] = "the store holds no manifest content for it" - default: - s.cache[d] = m - } - } - s.mu.Unlock() - } - - got := map[string]*Manifest{} - unread := map[string]string{} - s.mu.Lock() - defer s.mu.Unlock() - for _, name := range l.RepoNames() { - for d := range l.Repos[name].Revisions { - if m, ok := s.cache[d]; ok { - got[Key(name, d)] = m - } else if why, ok := failed[d]; ok { - unread[Key(name, d)] = why - } else { - unread[Key(name, d)] = "not read" - } - } - } - return got, unread -} - -// read reads one batch of manifests. A blob that is not a manifest, or not there, is answered as nil; -// an exec that fails fails the batch. -func (s *Store) read(ctx context.Context, digests []string) (map[string]*Manifest, error) { - args := []string{"exec", s.Container, "sh", "-c", readScript, "sh"} - byPath := map[string]string{} - for _, d := range digests { - if p := blobPath(d); p != "" { - byPath[p] = d - args = append(args, p) - } - } - out, err := docker(ctx, s.Run, s.UID, args...) - if err != nil { - return nil, err - } - return parseManifests(out, byPath) -} - -// parseManifests reads readScript's output. -func parseManifests(out string, byPath map[string]string) (map[string]*Manifest, error) { - got := map[string]*Manifest{} - current := "" - var body strings.Builder - flush := func() { - if d := byPath[current]; d != "" { - m, err := ParseManifest([]byte(body.String())) - if err != nil { - m = nil - } - got[d] = m - } - current = "" - body.Reset() - } - ended := false - for _, line := range strings.Split(out, "\n") { - switch { - case strings.HasPrefix(line, "#manifest "): - flush() - current = strings.TrimPrefix(line, "#manifest ") - case line == "#absent": - if d := byPath[current]; d != "" { - got[d] = nil - } - current = "" - body.Reset() - case line == "#end": - flush() - ended = true - default: - body.WriteString(line) - body.WriteString("\n") - } - } - if !ended { - return nil, fmt.Errorf("reading the store's manifests was cut short (it never reached its end)") - } - return got, nil -} diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/read_test.go b/modules/artifact-store-tools/cmd/artifact-store-tools/read_test.go deleted file mode 100644 index 0784fa757..000000000 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/read_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package main - -import "testing" - -func TestManifestsAreReadFromTheStoresFilesAndAMissingOneIsSaid(t *testing.T) { - got, err := parseManifests("#manifest blobs/sha256/aa/aa1/data\n"+image(d("cfg"), d("l"))+"\n\n#manifest blobs/sha256/bb/bb1/data\n#absent\n#manifest blobs/sha256/cc/cc1/data\nnot json\n#end\n", - map[string]string{"blobs/sha256/aa/aa1/data": "sha256:aa1", "blobs/sha256/bb/bb1/data": "sha256:bb1", "blobs/sha256/cc/cc1/data": "sha256:cc1"}) - if err != nil { - t.Fatal(err) - } - if m := got["sha256:aa1"]; m == nil || len(m.Layers) != 1 || m.Layers[0] != d("l") { - t.Errorf("aa1 %+v", m) - } - if got["sha256:bb1"] != nil || got["sha256:cc1"] != nil { - t.Error("an absent or unreadable manifest was read as one") - } - if _, err := parseManifests("#manifest x\n{}\n", map[string]string{"x": "sha256:x"}); err == nil { - t.Error("a cut read was accepted") - } - - f := world() - delete(f.manifests, d("m-old")) - v := view(t, f) - if v.Unread[Key("app/server", d("m-old"))] == "" { - t.Error("a manifest whose content is gone was not said as unread") - } - if out := Usage(v, 3); out["unread"] == nil { - t.Error("the answer did not say a manifest was unread") - } -} - -func TestADigestNeverBecomesAPathOutsideTheBlobs(t *testing.T) { - for _, bad := range []string{"sha256:", "nocolon", "sha256:a/../../etc", "sha256:ab cd"} { - if p := blobPath(bad); p != "" { - t.Errorf("%q became %q", bad, p) - } - } -} diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/runner.go b/modules/artifact-store-tools/cmd/artifact-store-tools/runner.go deleted file mode 100644 index 726309b64..000000000 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/runner.go +++ /dev/null @@ -1,96 +0,0 @@ -package main - -import ( - "bytes" - "context" - "errors" - "fmt" - "os/exec" - "regexp" - "strings" - "time" -) - -// Ran is what a command did: its output, its exit status, and why it never ran to an answer. -type Ran struct { - Stdout string - Stderr string - Status int - // Err is "ENOENT" when the program is not installed, or says it was ended for taking too long. - Err string -} - -// Runner runs one command, so every tool can be tested without a daemon. -type Runner func(ctx context.Context, name string, args ...string) Ran - -// ListTimeout is how long listing the store's files may take: below the runtime's thirty-second call -// limit, so a store that hangs is answered as such rather than as a call the runtime gave up on. -const ListTimeout = 15 * time.Second - -// ExecRunner runs a command on this machine, bounded by ListTimeout. -func ExecRunner(ctx context.Context, name string, args ...string) Ran { - ctx, cancel := context.WithTimeout(ctx, ListTimeout) - defer cancel() - cmd := exec.CommandContext(ctx, name, args...) - var out, errb bytes.Buffer - cmd.Stdout, cmd.Stderr = &out, &errb - err := cmd.Run() - r := Ran{Stdout: out.String(), Stderr: errb.String()} - var exitErr *exec.ExitError - switch { - case errors.Is(ctx.Err(), context.DeadlineExceeded): - r.Status, r.Err = 124, fmt.Sprintf("no answer within %d s", int(ListTimeout/time.Second)) - case errors.Is(err, exec.ErrNotFound): - r.Status, r.Err = 127, "ENOENT" - case errors.As(err, &exitErr): - r.Status = exitErr.ExitCode() - case err != nil: - r.Status, r.Err = 1, err.Error() - } - return r -} - -var socketRefused = regexp.MustCompile(`(?i)permission denied.*docker.*sock|docker\.sock.*permission denied`) - -// docker runs one docker command as this account and answers its stdout. A socket that refuses the -// account is asked again through `sudo -n`, as the container runtime's own tools do; never as root -// otherwise, and never with a prompt. -func docker(ctx context.Context, run Runner, uid int, args ...string) (string, error) { - r := run(ctx, "docker", args...) - program := "docker" - if r.Status != 0 && r.Err == "" && uid != 0 && socketRefused.MatchString(r.Stderr) { - program = "sudo" - r = run(ctx, "sudo", append([]string{"-n", "docker"}, args...)...) - } - if r.Status == 0 && r.Err == "" { - return r.Stdout, nil - } - said := strings.TrimSpace(r.Stderr + "\n" + r.Stdout) - switch { - case r.Err == "ENOENT" && program == "sudo": - return "", errors.New("the runtime's socket refused this account, and sudo is not installed here to escalate with") - case r.Err == "ENOENT": - return "", errors.New("docker is not installed on this machine, so the store's files cannot be listed") - case r.Err != "": - return "", fmt.Errorf("listing the store's files did not answer: %s", r.Err) - case program == "sudo" && strings.HasPrefix(said, "sudo:"): - return "", fmt.Errorf("the runtime's socket refused this account and it may not escalate without a prompt: %s", firstLine(said)) - case strings.Contains(said, "No such container"): - return "", fmt.Errorf("the store's container is not on this machine: %s", firstLine(said)) - case strings.Contains(said, "is not running"): - return "", fmt.Errorf("the store's container is not running: %s", firstLine(said)) - } - if l := firstLine(said); l != "" { - return "", fmt.Errorf("listing the store's files failed (%d): %s", r.Status, l) - } - return "", fmt.Errorf("listing the store's files failed with status %d", r.Status) -} - -func firstLine(s string) string { - for _, l := range strings.Split(s, "\n") { - if l = strings.TrimSpace(l); l != "" { - return l - } - } - return "" -} diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/seat.go b/modules/artifact-store-tools/cmd/artifact-store-tools/seat.go index 37491142d..67d33e2c2 100644 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/seat.go +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/seat.go @@ -1,6 +1,6 @@ package main -// The seat mesh-artifact-sweep's verbs (novox/hq ADR 0571 clause 2; catalogue.ArtifactSweepSeat), each a tool named +// The seat mesh-artifact-sweep's verbs (novox/hq ADR 0571 clause 2; sweep.Seat), each a tool named // `artifact-store.` as the runtime serves a seat's verb, answering as the controller's verb answered: {output, // ok, answer}. @@ -8,8 +8,8 @@ import ( "fmt" "strings" + sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/catalogue" ) // verbAnswer is the controller's verbs' answer shape, which the builder and docker read (answerOf). @@ -21,7 +21,7 @@ type verbAnswer struct { // SeatTools are the seat's verbs over h; s reads the store's listing for mirrors. func SeatTools(h *Holder, s *Store) []stdio.Tool { - seat := catalogue.ArtifactSweepSeat + "." + seat := sweep.Seat + "." return []stdio.Tool{ {Name: seat + "artifacts", Description: "Every artifact the mesh made that the store holds: kept and why, eligible, " + "or with collected those it no longer holds.", diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/store.go b/modules/artifact-store-tools/cmd/artifact-store-tools/store.go deleted file mode 100644 index 1214aedff..000000000 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/store.go +++ /dev/null @@ -1,180 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "sort" - "strconv" - "strings" -) - -// What the artifact store holds, read from its own files, and nothing written to them. -// -// **Why its files.** The store's door lists repositories and the tags in each, but not a manifest no tag -// names — and since the mesh pins every machine by digest, that is almost every manifest the store -// holds. The files list them all, with every blob's size, and hold every manifest's content. They are -// read through the store's own container, which is the only process that has them, and only read -// (novox/hq ADR 0251 §1). So this module runs on the machine that holds the store; anywhere else its -// tools say the container is not there. - -// storageRoot is where the registry keeps its files, inside its container. -const storageRoot = "/var/lib/registry/docker/registry/v2" - -// listScript lists, in three sections, every blob with its size, every manifest each repository holds, -// and what each tag names now. Busybox's find and stat (the store's image is Alpine): no -printf. An -// upload in progress is not listed, and nor are a tag's former targets. -const listScript = `set -e -cd ` + storageRoot + ` -echo '#blobs' -if [ -d blobs ]; then find blobs -type f -name data | xargs -r stat -c '%s %n'; fi -echo '#revisions' -if [ -d repositories ]; then find repositories -type f -path '*/_manifests/revisions/sha256/*/link'; fi -echo '#tags' -if [ -d repositories ]; then find repositories -type f -path '*/_manifests/tags/*/current/link' -exec grep -H . {} + || true; fi -echo '#end'` - -// Repo is one repository as the store's files list it. -type Repo struct { - Name string - // Revisions are the digests of every manifest it holds, tagged or not. - Revisions map[string]bool - // Tags are what each tag names now. - Tags map[string]string -} - -// Listing is the store's files: every blob with its size, and every repository. -type Listing struct { - Blobs map[string]int64 - Repos map[string]*Repo -} - -func (l *Listing) repo(name string) *Repo { - r := l.Repos[name] - if r == nil { - r = &Repo{Name: name, Revisions: map[string]bool{}, Tags: map[string]string{}} - l.Repos[name] = r - } - return r -} - -// RepoNames are the repositories in order. -func (l *Listing) RepoNames() []string { - out := make([]string, 0, len(l.Repos)) - for n := range l.Repos { - out = append(out, n) - } - sort.Strings(out) - return out -} - -// ParseListing reads listScript's output. A section that never ended means the listing was cut short, -// which is an error: a partial listing would make held things look absent. -func ParseListing(out string) (*Listing, error) { - l := &Listing{Blobs: map[string]int64{}, Repos: map[string]*Repo{}} - section := "" - ended := false - for _, line := range strings.Split(out, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - if strings.HasPrefix(line, "#") { - section = line - ended = line == "#end" - continue - } - switch section { - case "#blobs": - size, path, ok := strings.Cut(line, " ") - n, err := strconv.ParseInt(size, 10, 64) - if !ok || err != nil { - return nil, fmt.Errorf("the store's listing has a blob line it cannot read: %q", line) - } - // blobs/sha256/ab//data - parts := strings.Split(strings.TrimPrefix(path, "./"), "/") - if len(parts) != 5 || parts[0] != "blobs" || parts[4] != "data" { - return nil, fmt.Errorf("the store's listing has a blob at an unexpected place: %q", path) - } - l.Blobs[parts[1]+":"+parts[3]] = n - case "#revisions": - // repositories//_manifests/revisions/sha256//link - name, rest, ok := strings.Cut(strings.TrimPrefix(strings.TrimPrefix(line, "./"), "repositories/"), "/_manifests/revisions/") - parts := strings.Split(rest, "/") - if !ok || len(parts) != 3 || parts[2] != "link" { - return nil, fmt.Errorf("the store's listing has a manifest at an unexpected place: %q", line) - } - l.repo(name).Revisions[parts[0]+":"+parts[1]] = true - case "#tags": - // repositories//_manifests/tags//current/link:sha256: - path, digest, ok := strings.Cut(line, ":sha256:") - name, rest, ok2 := strings.Cut(strings.TrimPrefix(strings.TrimPrefix(path, "./"), "repositories/"), "/_manifests/tags/") - tag, _, ok3 := strings.Cut(rest, "/current/link") - if !ok || !ok2 || !ok3 || tag == "" { - return nil, fmt.Errorf("the store's listing has a tag line it cannot read: %q", line) - } - l.repo(name).Tags[tag] = "sha256:" + digest - default: - return nil, fmt.Errorf("the store's listing has a line outside any section: %q", line) - } - } - if !ended { - return nil, fmt.Errorf("the store's listing was cut short (it never reached its end)") - } - return l, nil -} - -// Manifest is what one manifest names. -type Manifest struct { - MediaType string - ConfigMedia string - // Refs are the blobs it names: its configuration and its layers. - Refs []string - // Layers are its layers alone, in order. - Layers []string - // Children are the manifests an index names. - Children []string -} - -// Holder is the shape of a manifest that keeps one blob: an empty configuration and one layer. The -// controller holds every archive the mesh keeps this way (novox/hq issue 253), and keeps a named -// document the same way, under a tag. -func (m *Manifest) Holder() bool { - return m != nil && m.ConfigMedia == "application/vnd.oci.empty.v1+json" && len(m.Layers) == 1 -} - -// ParseManifest reads a manifest's content, of any of the shapes a registry holds. -func ParseManifest(raw []byte) (*Manifest, error) { - type desc struct { - MediaType string `json:"mediaType"` - Digest string `json:"digest"` - } - var v struct { - MediaType string `json:"mediaType"` - Config *desc `json:"config"` - Layers []desc `json:"layers"` - Manifests []desc `json:"manifests"` - FSLayers []struct { - BlobSum string `json:"blobSum"` - } `json:"fsLayers"` - } - if err := json.Unmarshal(raw, &v); err != nil { - return nil, fmt.Errorf("not a manifest: %v", err) - } - m := &Manifest{MediaType: v.MediaType} - if v.Config != nil && v.Config.Digest != "" { - m.ConfigMedia = v.Config.MediaType - m.Refs = append(m.Refs, v.Config.Digest) - } - for _, d := range v.Layers { - m.Refs = append(m.Refs, d.Digest) - m.Layers = append(m.Layers, d.Digest) - } - for _, d := range v.FSLayers { - m.Refs = append(m.Refs, d.BlobSum) - m.Layers = append(m.Layers, d.BlobSum) - } - for _, d := range v.Manifests { - m.Children = append(m.Children, d.Digest) - } - return m, nil -} diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/store_test.go b/modules/artifact-store-tools/cmd/artifact-store-tools/store_test.go index ea6fdccd8..3729944df 100644 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/store_test.go +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/store_test.go @@ -1,13 +1,18 @@ package main import ( + reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest" + "git.novox.be/novox/mesh-sdk/go/views" "context" "encoding/json" "fmt" "os" "reflect" + "slices" "sort" "strconv" "strings" @@ -22,66 +27,72 @@ func d(name string) string { func hexOf(digest string) string { return strings.TrimPrefix(digest, "sha256:") } -// fakeStore is a store's files and its manifests: what listScript and readScript would print. +// fakeStore is a store's blobs and manifests, as the two registry seats' list answers them (ADR 0632 §4). type fakeStore struct { blobs map[string]int64 revisions map[string][]string // repo -> digests tags map[string]map[string]string manifests map[string]string // digest -> content + held map[string]bool // Key(repo, digest) -> held } -func (f *fakeStore) listing() string { - var b strings.Builder - b.WriteString("#blobs\n") - for dg, n := range f.blobs { - fmt.Fprintf(&b, "%d blobs/sha256/%s/%s/data\n", n, hexOf(dg)[:2], hexOf(dg)) - } - b.WriteString("#revisions\n") - for repo, ds := range f.revisions { - for _, dg := range ds { - fmt.Fprintf(&b, "repositories/%s/_manifests/revisions/sha256/%s/link\n", repo, hexOf(dg)) +// ask is the seats as the runtime asks them: list on each, every repository or one; anything else fails the test, as +// these tools only read. +func (f *fakeStore) ask(t *testing.T) func(string, any) (json.RawMessage, error) { + return func(key string, body any) (json.RawMessage, error) { + seat, verb, _ := strings.Cut(strings.TrimPrefix(key, "seat:"), ".") + if verb != "list" || (seat != "container-image-registry" && seat != "oci-artifact-registry") { + t.Fatalf("asked %s", key) } - } - b.WriteString("#tags\n") - for repo, ts := range f.tags { - for t, dg := range ts { - fmt.Fprintf(&b, "repositories/%s/_manifests/tags/%s/current/link:%s\n", repo, t, dg) + raw, _ := json.Marshal(body) + var in reg.ListInput + _ = json.Unmarshal(raw, &in) + repos := []reg.Repository{} + names := make([]string, 0, len(f.revisions)) + for n := range f.revisions { + names = append(names, n) } - } - b.WriteString("#end\n") - return b.String() -} - -// run is the store's container as docker exec reaches it: the listing, or the manifests asked for, as -// the two scripts print them. Anything else is a test failure: these tools only read. -func (f *fakeStore) run(t *testing.T) Runner { - return func(_ context.Context, name string, args ...string) Ran { - if name != "docker" || len(args) < 5 || args[0] != "exec" || args[1] != "mesh-registry" || args[2] != "sh" || args[3] != "-c" { - t.Fatalf("ran %s %v", name, args) - } - switch args[4] { - case listScript: - return Ran{Stdout: f.listing()} - case readScript: - var b strings.Builder - for _, p := range args[6:] { - fmt.Fprintf(&b, "#manifest %s\n", p) - found := false - for dg, body := range f.manifests { - if blobPath(dg) == p { - b.WriteString(body + "\n") - found = true + sort.Strings(names) + for _, name := range names { + if in.Name != "" && in.Name != name { + continue + } + var ms []reg.Listed + for _, dg := range f.revisions[name] { + var doc struct { + Config *struct{ MediaType, Digest string } + Layers []struct{ Digest string } + Manifests []struct{ Digest string } + } + _ = json.Unmarshal([]byte(f.manifests[dg]), &doc) + e := reg.Listed{Digest: dg, Size: f.blobs[dg], Held: f.held[Key(name, dg)]} + if doc.Config != nil { + e.Size += f.blobs[doc.Config.Digest] + } + for _, l := range doc.Layers { + e.Size += f.blobs[l.Digest] + } + for _, c := range doc.Manifests { + e.Platforms = append(e.Platforms, c.Digest) + } + e.Index = len(e.Platforms) > 0 + if doc.Config != nil && doc.Config.MediaType == "application/vnd.oci.empty.v1+json" && len(doc.Layers) == 1 { + e.Artifact = doc.Layers[0].Digest + } + for tag, at := range f.tags[name] { + if at == dg { + e.Tags = append(e.Tags, tag) } } - if !found { - b.WriteString("#absent\n") + if (e.Artifact != "") == (seat == "oci-artifact-registry") { + ms = append(ms, e) } } - b.WriteString("#end\n") - return Ran{Stdout: b.String()} + if len(ms) > 0 { + repos = append(repos, reg.Repository{Name: name, Manifests: ms}) + } } - t.Fatalf("ran a script these tools do not have: %q", args[4]) - return Ran{} + return json.Marshal(reg.ListAnswer{Repositories: repos}) } } @@ -153,7 +164,7 @@ func records() *Records { func view(t *testing.T, f *fakeStore) *View { t.Helper() - s := &Store{Container: "mesh-registry", UID: 1000, Run: f.run(t)} + s := &Store{Ask: f.ask(t)} v, err := s.View(context.Background()) if err != nil { t.Fatal(err) @@ -168,50 +179,34 @@ func withFill(r *Records) *Records { return r } -func TestTheListingIsRead(t *testing.T) { - l, err := ParseListing("#blobs\n12 blobs/sha256/ab/abcd/data\n#revisions\nrepositories/a/b/c/_manifests/revisions/sha256/ff/link\n#tags\nrepositories/a/b/c/_manifests/tags/v1/current/link:sha256:ff\n#end\n") - if err != nil { - t.Fatal(err) +// The store is read through its two seats' list (ADR 0632 §4): every manifest of every repository, tagged or not, an +// index with the manifests it names, a bundle's or archive's manifest as the holder of its blob; each manifest's size is +// the seat's, so nothing is unmarked before a manifest goes, and a manifest gone frees its size. +func TestTheStoreIsReadThroughItsSeats(t *testing.T) { + f := world() + f.held = map[string]bool{Key("app/tools", d("h-kept")): true} + v := view(t, f) + server := v.L.Repos["app/server"] + if server == nil || len(server.Revisions) != 4 || server.Tags["latest"] != d("m-stranger") { + t.Fatalf("app/server reads %+v", server) } - if l.Blobs["sha256:abcd"] != 12 { - t.Errorf("blobs %v", l.Blobs) + if m := v.M[Key("app/server", d("idx"))]; m == nil || !reflect.DeepEqual(m.Children, []string{d("m-kept")}) { + t.Errorf("the index names %+v", m) } - r := l.Repos["a/b/c"] - if r == nil || !r.Revisions["sha256:ff"] || r.Tags["v1"] != "sha256:ff" { - t.Errorf("repository with slashes misread: %+v", r) + if m := v.M[Key("app/tools", d("h-kept"))]; !m.Holder() || m.Layers[0] != d("a-kept") { + t.Errorf("the holder of a-kept reads %+v", m) } -} - -func TestACutListingIsRefused(t *testing.T) { - for _, out := range []string{"#blobs\n12 blobs/sha256/ab/abcd/data\n", "#blobs\nnot a line\n#end\n", "stray\n#end\n"} { - if _, err := ParseListing(out); err == nil { - t.Errorf("accepted %q", out) - } + if _, held := v.L.Blobs[d("a-kept")]; !held || !v.Held[Key("app/tools", d("h-kept"))] { + t.Errorf("a-kept is not read as held by the store, or its hold is not read: %v", v.Held) } -} - -func TestTheMarkIsTheCollectorsIncludingAnIndexAndSharedBlobs(t *testing.T) { - v := view(t, world()) - marked := v.Marked(nil) - for _, name := range []string{"cfg", "shared", "old-only", "stranger-layer", "a-kept", "a-old", "doc-body", "empty", "m-kept", "idx"} { - if !marked[d(name)] { - t.Errorf("%s not marked", name) - } + if want := int64(len(f.manifests[d("m-old")])) + 10 + 1000 + 500; v.L.Blobs[d("m-old")] != want { + t.Errorf("m-old's size %d, want %d (its content and every blob it names)", v.L.Blobs[d("m-old")], want) } - if marked[d("orphan")] { - t.Error("a blob no manifest names is marked") + if freed, n := v.Unmarked(nil); freed != 0 || n != 0 { + t.Errorf("unmarked %d in %d before anything goes", freed, n) } - freed, n := v.Unmarked(nil) - if freed != 7000 || n != 1 { - t.Errorf("the collector frees %d in %d, want 7000 in 1", freed, n) - } - // The index alone gone frees its own content and nothing m-kept still marks. - _, n = v.Unmarked(map[string]bool{Key("app/server", d("idx")): true}) - if n != 2 { - t.Errorf("without the index %d blobs unmarked, want 2 (orphan and the index itself)", n) - } - if sh := v.Shared(); !sh[d("empty")] || sh[d("cfg")] { - t.Error("shared misjudged") + if freed, n := v.Unmarked(map[string]bool{Key("app/server", d("idx")): true}); freed != v.L.Blobs[d("idx")] || n != 1 { + t.Errorf("the index gone frees %d in %d, want its size", freed, n) } } @@ -255,16 +250,15 @@ func TestAStateThisBundleDoesNotKnowIsKept(t *testing.T) { } } -func TestBytesFreedCountASharedBlobAsKept(t *testing.T) { +func TestBytesFreedAreTheSizesOfWhatGoes(t *testing.T) { v := view(t, world()) a := &CollectAnswer{DryRun: true, Eligible: 2, WouldLetGo: []string{ "artifact-store://app/server@" + d("m-old"), "artifact-store://app/tools/blobs/" + d("a-old")}} out := Collected(v, a, true, nil) now, after := out["collector_frees_now_bytes"].(int64), out["collector_frees_after_bytes"].(int64) - // m-old's own content, old-only and a-old and its holder go; shared and cfg stay with m-kept. - want := int64(7000) + 500 + 4000 + v.L.Blobs[d("m-old")] + v.L.Blobs[d("h-old")] - if now != 7000 || after != want { - t.Errorf("now %d after %d, want 7000 and %d", now, after, want) + // m-old and the holder of a-old go, each with the size its seat says. + if want := v.L.Blobs[d("m-old")] + v.L.Blobs[d("h-old")]; now != 0 || after != want { + t.Errorf("now %d after %d, want 0 and %d", now, after, want) } if out["manifests_gone"].(int) != 2 { t.Errorf("gone %v", out["manifests_gone"]) @@ -275,8 +269,7 @@ func TestOnlyTheirBytesLeaveOutWhatAnotherStateMarks(t *testing.T) { v := view(t, world()) entries := Classify(v, withFill(records()), nil) sums := Summarise(v, entries, entries) - el := sums[StateEligible] - if el.OnlyTheirBytes != 500+v.L.Blobs[d("m-old")] { + if el := sums[StateEligible]; el.OnlyTheirBytes != v.L.Blobs[d("m-old")] { t.Errorf("eligible only-theirs %d", el.OnlyTheirBytes) } } @@ -393,7 +386,7 @@ func (a *asked) ask(key string, body any) (json.RawMessage, error) { func tool(t *testing.T, a *asked, name string) func(map[string]any) (any, error) { f := world() - s := &Store{Container: "mesh-registry", Run: f.run(t)} + s := &Store{Ask: f.ask(t)} for _, tl := range Tools(s, recordsAsked{a}, &Catalogue{Ask: a.ask}) { if tl.Name == name { return tl.Run @@ -443,10 +436,12 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) { t.Fatal(err) } var m struct { - Tools []string `json:"tools"` - Invokes []string `json:"invokes"` - Reads []string `json:"reads"` - Claims []struct { + Tools []string `json:"tools"` + Invokes []string `json:"invokes"` + Reads []string `json:"reads"` + Consumes []string `json:"consumes"` + Requires []any `json:"requires"` + Claims []struct { Name string `json:"name"` Serves []string `json:"serves"` } `json:"claims"` @@ -455,7 +450,7 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) { t.Fatal(err) } var served []string - for _, tl := range Tools(&Store{}, recordsAsked{&asked{}}, &Catalogue{}) { + for _, tl := range append(Tools(&Store{}, recordsAsked{&asked{}}, &Catalogue{}), (&views.Reader{}).Tools("artifact_store")...) { if !strings.HasPrefix(tl.Name, "artifact_store_") || tl.Description == "" || tl.Run == nil { t.Errorf("tool %q", tl.Name) } @@ -469,20 +464,41 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) { } // ADR 0571 clause 2: it holds the seat mesh-artifact-sweep and serves its verbs, and calls no verb of the controller's; // it reads no controller state, the lease neither (issue 1126). - want := []string{"seat:mesh-hand-acts.hand-act", CatalogueModules, CatalogueModule} + // CAT-AS1: what it holds and removes it asks the two registry seats the artifact store's holder serves. + want := []string{"seat:mesh-hand-acts.hand-act", CatalogueModules, CatalogueModule, WantsTool, ManifestsTool, + "seat:oci-artifact-registry.hold", "seat:oci-artifact-registry.release", "seat:oci-artifact-registry.remove", + "seat:container-image-registry.hold", "seat:container-image-registry.release", "seat:container-image-registry.remove", + // ADR 0632 §4: what the store holds it reads through the two seats' list. + "seat:container-image-registry.list", "seat:oci-artifact-registry.list"} if !reflect.DeepEqual(m.Invokes, want) { t.Errorf("invokes %v", m.Invokes) } + // ADR 0615: the assigner's wants and the indexer's manifests are asked once and followed by their events, not read. + if !reflect.DeepEqual(m.Consumes, []string{"mesh-delivery-assigner.views.wants", "mesh-delivery-indexer.views.manifests"}) || + slices.Contains(m.Reads, WantsRead) || slices.Contains(m.Reads, ManifestsRead) { + t.Errorf("consumes %v, reads %v", m.Consumes, m.Reads) + } + // The operator, 2026-11-04 (issue 1966): it reaches the store through the registry seats' verbs, and requires no + // provision artifact-store. + if len(m.Requires) != 0 { + t.Errorf("requires %v, want nothing: the store is reached through the registry seats", m.Requires) + } + // Its address file is filled from the image seat holder's listen (ADR 0632 §1), never by the retired provision. + address, err := os.ReadFile("../../files/store-address") + if err != nil || strings.TrimSpace(string(address)) != "${seat:container-image-registry:reach}" { + t.Errorf("the store's address file reads %q (%v), want the image seat's reach", address, err) + } var verbs []string for _, tl := range SeatTools(&Holder{}, &Store{}) { - verbs = append(verbs, strings.TrimPrefix(tl.Name, catalogue.ArtifactSweepSeat+".")) + verbs = append(verbs, strings.TrimPrefix(tl.Name, sweep.Seat+".")) } - seat, _ := catalogue.SeatNamed(catalogue.ArtifactSweepSeat) + catalogue.UseSeats(catalogue.SeatsFromDefinitions(seatlisttest.Definitions())) + seat, _ := catalogue.SeatNamed(sweep.Seat) var contract []string for _, v := range seat.Serves { contract = append(contract, v.Name) } - if len(m.Claims) != 1 || m.Claims[0].Name != catalogue.ArtifactSweepSeat || !reflect.DeepEqual(m.Claims[0].Serves, verbs) || + if len(m.Claims) != 1 || m.Claims[0].Name != sweep.Seat || !reflect.DeepEqual(m.Claims[0].Serves, verbs) || !reflect.DeepEqual(verbs, contract) { t.Errorf("claims %+v, serves %v, the seat's verbs %v", m.Claims, verbs, contract) } @@ -492,25 +508,3 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) { } } } - -func TestASocketRefusalIsAskedAgainThroughSudo(t *testing.T) { - var ran []string - run := func(_ context.Context, name string, args ...string) Ran { - ran = append(ran, name) - if name == "docker" { - return Ran{Status: 1, Stderr: "permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock"} - } - return Ran{Stdout: "#end\n"} - } - if _, err := docker(context.Background(), run, 1000, "exec", "x"); err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(ran, []string{"docker", "sudo"}) { - t.Errorf("ran %v", ran) - } - if _, err := docker(context.Background(), func(context.Context, string, ...string) Ran { - return Ran{Status: 1, Stderr: "Error: No such container: mesh-registry"} - }, 1000, "exec"); err == nil || !strings.Contains(err.Error(), "not on this machine") { - t.Errorf("err %v", err) - } -} diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/sweepseats.go b/modules/artifact-store-tools/cmd/artifact-store-tools/sweepseats.go new file mode 100644 index 000000000..568b62781 --- /dev/null +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/sweepseats.go @@ -0,0 +1,153 @@ +package main + +// **The sweep asks the registry seats** (CAT-AS1; novox/hq ADR 0617 §2, design 51): the artifact store's write side is +// its holder's, so the sweep no longer deletes or holds over the store's door. What it keeps from retention it asks the +// seat's `hold` for (a kept bundle or archive on oci-artifact-registry, a kept copy of an upstream base on +// container-image-registry), and what is eligible it asks the seat's `remove` for, with the sweep's reason; both verbs +// are in the manifest's `invokes`, and only this module is on their caller lists. A reference is read in both forms +// (ADR 0605 §9): the older `artifact-store://` and the seat's `//@`; the keep set +// keys each by the older form, so the two meet as one artifact. A call a seat refuses stops the sweep and writes the +// sweep's finding, sweep-refused; the next sweep with no refusal clears it. + +import ( + "encoding/json" + "fmt" + "strings" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + artifactref "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient/reference" + ociseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry" + "git.novox.be/novox/mesh-sdk/go/catalogue" + + reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" +) + +// SweepRefused is the sweep's finding: a seat refused a hold or a remove the sweep asked. +const SweepRefused = "sweep-refused" + +// sweepFindingKey is the one key of the sweep's finding in the module's findings. +var sweepFindingKey = meshconditionsclient.Key(meshconditionsclient.ScopeModule, Module, SweepRefused) + +// canonical is a kept reference in the older form, `artifact-store://`, whichever form it came in; another is as +// it was. +func canonical(reference string) string { + path, kept := registryForms.InArtifactStore(reference) + if !kept { + return reference + } + return catalogue.ArtifactStoreScheme + path +} + +// seatOf is the seat, name and digest a kept reference is held under: a bundle or an archive on oci-artifact-registry, +// an image or a copy of an upstream base on container-image-registry. +func seatOf(reference string) (seat, name, digest string, err error) { + path, kept := registryForms.InArtifactStore(reference) + if !kept { + return "", "", "", fmt.Errorf("%s is no reference into the mesh's registries", reference) + } + if name, digest, ok := strings.Cut(path, "/blobs/"); ok { + return ociseat.Seat, name, digest, nil + } + if name, digest, ok := strings.Cut(path, "@"); ok { + return reg.Seat, name, digest, nil + } + return "", "", "", fmt.Errorf("%s names no digest", reference) +} + +// isCopy is whether a kept reference is a copy of an upstream base. +func isCopy(reference string) bool { + path, _ := registryForms.InArtifactStore(reference) + return strings.HasPrefix(path, "upstream/") +} + +// askSeat asks verb of the seat reference is held on, with body built from its name and digest. +func (h *Holder) askSeat(reference, verb string, body func(name, digest string) any, answer any) error { + seat, name, digest, err := seatOf(reference) + if err != nil { + return err + } + if h.Seats == nil { + return fmt.Errorf("this process reaches no registry seat") + } + raw, err := h.Seats("seat:"+seat+"."+verb, body(name, digest)) + if err != nil { + return fmt.Errorf("%s's %s of %s@%s: %w", seat, verb, name, digest, err) + } + if answer != nil && len(raw) > 0 { + if err := json.Unmarshal(raw, answer); err != nil { + return fmt.Errorf("%s's answer to %s: %w", seat, verb, err) + } + } + return nil +} + +// holdOnSeat asks the seat to keep reference from retention, for why. +func (h *Holder) holdOnSeat(reference, why string) (bool, error) { + var a reg.HoldAnswer + err := h.askSeat(reference, reg.VerbHold, func(name, digest string) any { + return reg.HoldInput{Name: name, Digest: digest, Reason: why} + }, &a) + if err == nil && !a.Held { + err = fmt.Errorf("%s: the seat answered it is not held", reference) + } + return a.Held, err +} + +// Retention is the reason of a removal by digest, the sweep's alone (design 51 *Who may call which verb*): the holder +// removes an untagged thing only for it. The seat's definition names it. +const Retention = reg.RemoveInputReasonRetention + +// removeOnSeat lets reference go: a hold an earlier sweep asked while it was kept is released first (the holder +// refuses to remove a held thing; one not held answers released false), then it is removed for retention. why goes with +// the release; the hand act records it. +func (h *Holder) removeOnSeat(reference, why string) error { + if err := h.askSeat(reference, reg.VerbRelease, func(name, digest string) any { + return reg.ReleaseInput{Name: name, Digest: digest, Reason: why} + }, ®.ReleaseAnswer{}); err != nil { + return err + } + return h.askSeat(reference, reg.VerbRemove, func(name, digest string) any { + return reg.RemoveInput{Name: name, Digest: digest, Reason: Retention} + }, ®.RemoveAnswer{}) +} + +// sweepFinding writes the sweep's finding: what the seat refused, and why. +func (h *Holder) sweepFinding(what string, err error) { + at := h.now().UTC() + f := meshconditions.FindingsByKeyValue{V: meshconditionsclient.FindingVersion, Key: sweepFindingKey, Kind: SweepRefused, + Subject: meshconditions.Subject{Scope: meshconditionsclient.ScopeModule, ID: Module}, + Severity: meshconditionsclient.Warning, Resolver: meshconditionsclient.ResolverOperator, + Summary: fmt.Sprintf("the artifact sweep stopped: a registry seat refused %s (the evidence names why)", what), + Headline: "The artifact sweep stopped", + Explanation: "The sweep keeps what the mesh still needs and removes the rest through the registry seats. A seat " + + "refused one of its asks, so it stopped before anything more was removed; what it kept stays kept.", + Needs: "read the seat holder's journal for the refusal; the next sweep asks again.", + Resolved: "Resolved: the artifact sweep ran through without a refusal", + Evidence: []meshconditions.Evidence{{At: at.UTC(), Said: fmt.Sprintf("%s: %v", what, err)}}, + Source: "CAT-AS1", Observer: Module, At: at} + if werr := h.Bus.Put(FindingsState, sweepFindingKey, f); werr != nil { + h.logf("the sweep's finding (%s: %v) could not be written: %v", what, err, werr) + } +} + +// sweepClean clears the sweep's finding after a sweep no seat refused. +func (h *Holder) sweepClean() { + if _, found, err := h.Bus.Get(FindingsState, sweepFindingKey); err == nil && found { + if err := h.Bus.Delete(FindingsState, sweepFindingKey); err != nil { + h.logf("the sweep's finding could not be cleared: %v", err) + } + } +} + +// keptWhy is why a kept reference is held, as the seat's hold takes it. +func keptWhy(r *Ref) string { + if r == nil || len(r.Why) == 0 { + return "kept by the artifact sweep" + } + return "kept: " + strings.Join(r.Why, "; ") +} + +// registryForms reads and routes a reference of the registry form, with the two registry seats' names from their contracts +// (the registry clients' reference forms, novox/hq ADR 0605 §9, ADR 0634 §4). +var registryForms = artifactref.Seats{Image: reg.Seat, Object: ociseat.Seat} diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/templates_test.go b/modules/artifact-store-tools/cmd/artifact-store-tools/templates_test.go index 7d0718b57..f429f1ed9 100644 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/templates_test.go +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/templates_test.go @@ -33,7 +33,8 @@ func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { t.Fatal(err) } var m struct { - Resources []map[string]any `json:"resources"` + Resources []map[string]any `json:"resources"` + Packages map[string]string `json:"packages"` Build struct { Artifacts []map[string]any `json:"artifacts"` } `json:"build"` @@ -41,6 +42,10 @@ func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } + // The builder's keys are read through its published package, taken by its series (ADR 0634 §1). + if m.Packages["builds"] != "0.1" { + t.Errorf("packages %v: want builds of series 0.1", m.Packages) + } for _, r := range m.Resources { if _, inline := r["content"]; inline { t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/tools.go b/modules/artifact-store-tools/cmd/artifact-store-tools/tools.go index daaab4859..7125aa5b7 100644 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/tools.go +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/tools.go @@ -27,9 +27,9 @@ func Tools(s *Store, c Recorded, cat *Catalogue) []stdio.Tool { { Name: "artifact_store_repositories", Description: "Every repository the artifact store holds: its tags and what each names, how many manifests it holds " + - "(tagged or not — the mesh pins by digest, so most are untagged) and its size, the blobs its manifests mark. " + - "A blob two repositories share is counted in each, and shared_bytes says how much of a size that is. Read from " + - "the store's own files; changes nothing. Replaces curl /v2/_catalog and /v2//tags/list. (r)", + "(tagged or not — the mesh pins by digest, so most are untagged) and its size, each manifest's as its seat says it. " + + "A layer two manifests share is counted in each. Read through the registry seats' list (ADR 0632 §4); changes " + + "nothing. Replaces curl /v2/_catalog and /v2//tags/list. (r)", Input: map[string]any{"repository": repoArg}, Run: func(args map[string]any) (any, error) { v, err := s.View(ctx) @@ -41,9 +41,9 @@ func Tools(s *Store, c Recorded, cat *Catalogue) []stdio.Tool { }, { Name: "artifact_store_usage", - Description: "How large the artifact store is: every blob's bytes together, the largest repositories, the bytes " + - "more than one repository's manifests mark, and the bytes no manifest marks — what the store's nightly collector " + - "frees next. Read from the store's own files; changes nothing. Replaces du on the store's directory. (r)", + Description: "How large the artifact store is: every manifest's size together, as its seat says it (a layer two " + + "manifests share is counted in each), and the largest repositories. Read through the registry seats' list (ADR " + + "0632 §4); changes nothing. Replaces du on the store's directory. (r)", Input: map[string]any{"top": map[string]any{"type": "integer", "description": "how many of the largest repositories to name (default 10, at most 200)"}}, Run: func(args map[string]any) (any, error) { top, err := count(args, "top", 10, 1, 200) @@ -139,16 +139,6 @@ func Tools(s *Store, c Recorded, cat *Catalogue) []stdio.Tool { } } -// View reads the store now. -func (s *Store) View(ctx context.Context) (*View, error) { - l, err := s.List(ctx) - if err != nil { - return nil, err - } - m, unread := s.Manifests(ctx, l) - return &View{L: l, M: m, Unread: unread}, nil -} - // unreadNote says what manifests that could not be read do to the numbers, when there are any. func unreadNote(v *View) map[string]any { if len(v.Unread) == 0 { diff --git a/modules/artifact-store-tools/cmd/artifact-store-tools/view.go b/modules/artifact-store-tools/cmd/artifact-store-tools/view.go index 9d853a0df..71578d42a 100644 --- a/modules/artifact-store-tools/cmd/artifact-store-tools/view.go +++ b/modules/artifact-store-tools/cmd/artifact-store-tools/view.go @@ -4,13 +4,15 @@ import ( "sort" ) -// View is the store at one moment: its files, and every manifest's content that could be read. +// View is the store at one moment, as its seats list it (listed.go). type View struct { L *Listing // M is each manifest read, by Key. M map[string]*Manifest // Unread is each manifest that could not be read, by Key, with why. Unread map[string]string + // Held is each manifest a hold on its seat keeps from retention, by Key. + Held map[string]bool } // Marks are the blobs one manifest keeps from the store's collector: its own content, its diff --git a/modules/artifact-store-tools/files/store-address b/modules/artifact-store-tools/files/store-address index 1bcadab26..71418ef2a 100644 --- a/modules/artifact-store-tools/files/store-address +++ b/modules/artifact-store-tools/files/store-address @@ -1 +1 @@ -${seat:mesh-artifact-store:reach} +${seat:container-image-registry:reach} diff --git a/modules/artifact-store-tools/go.mod b/modules/artifact-store-tools/go.mod index f24993702..2e3263e2d 100644 --- a/modules/artifact-store-tools/go.mod +++ b/modules/artifact-store-tools/go.mod @@ -3,20 +3,36 @@ module artifactstoretools go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 + git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 github.com/nats-io/nats.go v1.54.0 ) require ( - github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect + github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/minio/highwayhash v1.0.4 // indirect - github.com/nats-io/jwt/v2 v2.8.1 // indirect + github.com/nats-io/jwt/v2 v2.8.2 // indirect github.com/nats-io/nats-server/v2 v2.11.17 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect golang.org/x/crypto v0.57.0 // indirect golang.org/x/sys v0.48.0 // indirect - golang.org/x/time v0.15.0 // indirect + golang.org/x/time v0.16.0 // indirect ) + +require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 // indirect diff --git a/modules/artifact-store-tools/go.sum b/modules/artifact-store-tools/go.sum index 7679a5dc3..8ca93dfd0 100644 --- a/modules/artifact-store-tools/go.sum +++ b/modules/artifact-store-tools/go.sum @@ -1,15 +1,13 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= -github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= -github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc= +github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc= github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= @@ -23,5 +21,5 @@ golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZ golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= diff --git a/modules/artifact-store-tools/internal/registry/blob.go b/modules/artifact-store-tools/internal/registry/blob.go deleted file mode 100644 index bedbfa38c..000000000 --- a/modules/artifact-store-tools/internal/registry/blob.go +++ /dev/null @@ -1,57 +0,0 @@ -package registry - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "fmt" - "io" - "net/http" -) - -// blobBound is the most GetBlob reads: a templates archive is a few kilobytes; anything near this is no archive a -// take-in reads text from. -const blobBound = 64 << 20 - -// GetBlob is the archive a recorded reference names (`artifact-store:///blobs/sha256:`), read whole -// and checked against the digest it names: bytes that hash to another digest are refused, never read (R4e: the -// controller reads a templates archive by the reference of a builder's done key). Gone when the store does not hold -// it; ErrNotOurs for a reference outside the store or one that is not an archive. -func (s Store) GetBlob(ctx context.Context, reference string) ([]byte, error) { - repository, digest, archive, err := s.archive(reference) - if err != nil { - return nil, err - } - if !archive { - return nil, fmt.Errorf("%w: %s is not an archive", ErrNotOurs, reference) - } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, s.url(repository, "blobs", digest), nil) - if err != nil { - return nil, err - } - response, err := s.client().Do(request) - if err != nil { - return nil, fmt.Errorf("cannot reach the artifact store at %s: %w", s.Address, err) - } - defer response.Body.Close() - switch response.StatusCode { - case http.StatusOK: - case http.StatusNotFound: - return nil, Gone - default: - return nil, fmt.Errorf("the artifact store answered %s for %s/blobs/%s", response.Status, repository, digest) - } - body, err := io.ReadAll(io.LimitReader(response.Body, blobBound+1)) - if err != nil { - return nil, fmt.Errorf("reading %s/blobs/%s: %w", repository, digest, err) - } - if len(body) > blobBound { - return nil, fmt.Errorf("%s/blobs/%s is larger than %d bytes, which no archive read here is", repository, digest, blobBound) - } - sum := sha256.Sum256(body) - if got := "sha256:" + hex.EncodeToString(sum[:]); got != digest { - return nil, fmt.Errorf("the artifact store answered %s/blobs/%s with bytes of the digest %s: refused", repository, - digest, got) - } - return body, nil -} diff --git a/modules/artifact-store-tools/internal/registry/blob_test.go b/modules/artifact-store-tools/internal/registry/blob_test.go deleted file mode 100644 index 13487cc7b..000000000 --- a/modules/artifact-store-tools/internal/registry/blob_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package registry - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -// A take-in reads a templates archive by its reference, and only bytes of the digest the reference names. -func TestGetBlobReadsOnlyTheBytesOfItsDigest(t *testing.T) { - good := []byte("the archive") - sum := sha256.Sum256(good) - digest := "sha256:" + hex.EncodeToString(sum[:]) - liar := "sha256:" + strings.Repeat("0", 64) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case strings.HasSuffix(r.URL.Path, "/x/files/blobs/"+digest), strings.HasSuffix(r.URL.Path, "/x/files/blobs/"+liar): - _, _ = w.Write(good) - default: - w.WriteHeader(http.StatusNotFound) - } - })) - defer server.Close() - s := Store{Address: strings.TrimPrefix(server.URL, "http://")} - ctx := context.Background() - body, err := s.GetBlob(ctx, "artifact-store://x/files/blobs/"+digest) - if err != nil || string(body) != string(good) { - t.Fatalf("the archive: %q %v", body, err) - } - if _, err := s.GetBlob(ctx, "artifact-store://x/files/blobs/"+liar); err == nil || !strings.Contains(err.Error(), "refused") { - t.Fatalf("bytes of another digest are read: %v", err) - } - if _, err := s.GetBlob(ctx, "artifact-store://x/other/blobs/"+digest); !errors.Is(err, Gone) { - t.Fatalf("a blob the store does not hold: %v", err) - } - if _, err := s.GetBlob(ctx, "registry.example/x@"+digest); !errors.Is(err, ErrNotOurs) { - t.Fatalf("a reference outside the store: %v", err) - } -} diff --git a/modules/artifact-store-tools/internal/registry/hold.go b/modules/artifact-store-tools/internal/registry/hold.go deleted file mode 100644 index 391e9c894..000000000 --- a/modules/artifact-store-tools/internal/registry/hold.go +++ /dev/null @@ -1,345 +0,0 @@ -package registry - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "net/http" - "strconv" - "strings" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// Every archive the store keeps is held by a manifest (novox/hq issue 253, ADR 0189). -// -// **The store's collector marks only from manifests.** The mesh's store is a stock registry, and -// its nightly `registry garbage-collect` walks every manifest in every repository, marks the blobs -// those manifests name, and deletes every blob it did not mark. An image is a manifest, so what -// the mesh keeps of an image survives. An archive was not: the builder put it in the store as a -// bare blob — upload, then `PUT ?digest=` — and nothing in the store names it. To the collector a -// bare blob is unreferenced, so the first real collection would have deleted every archive the -// mesh holds, kept or not, and every machine pinning a bundle would have found it gone. The -// collector runs `--dry-run` until this is true. -// -// **So each archive gets a holder**: the smallest OCI image manifest that names it — the empty -// config, one layer, nothing else — put in the archive's own repository, by digest, untagged. The -// collector marks it and so keeps the archive; the sweep lets go of an archive by deleting its -// holder first, which is what lets the bytes go at the next collection. -// -// **Nothing a machine reads changes.** The recorded reference stays -// `artifact-store:////blobs/sha256:…`, and machines fetch the blob exactly as -// before. The holder is the store's bookkeeping, not a second way to reach anything. -// -// **Deterministic, so it never needs recording.** The holder is composed from the archive's digest -// and size alone, in a fixed field order with no timestamps or annotations, so the sweep can -// compute which manifest holds any archive from the reference it already has plus one HEAD for the -// size. No schema change, no second record that could disagree with the store. - -const ( - // mediaManifest is the type a holder is put and asked for as. - mediaManifest = "application/vnd.oci.image.manifest.v1+json" - // mediaEmpty is the OCI empty descriptor's type: a config that says nothing, for a manifest - // whose only purpose is to name its layer. - mediaEmpty = "application/vnd.oci.empty.v1+json" - // emptyDigest is the digest of `{}`, the empty config's content, fixed by the OCI spec. - emptyDigest = "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" - // mediaArchive is the layer type an archive is held as. Every archive the builder publishes is - // `pack`'s gzipped tar, so this is the true type and not a placeholder — and it is a constant, - // not read from anywhere, because the holder must be recomputable from the reference alone. - mediaArchive = "application/vnd.oci.image.layer.v1.tar+gzip" -) - -// emptyConfig is the content emptyDigest names. -var emptyConfig = []byte("{}") - -// manifestAccept is what a manifest is asked for as. A registry answers a manifest HEAD only in a -// type the caller named, and answers 404 to a bare one for a manifest it holds perfectly well -// (measured 2026-09-28; internal/builder/registry.go says how that was found). -var manifestAccept = []string{ - mediaManifest, - "application/vnd.docker.distribution.manifest.v2+json", -} - -type descriptor struct { - MediaType string `json:"mediaType"` - Digest string `json:"digest"` - Size int64 `json:"size"` -} - -type holderManifest struct { - SchemaVersion int `json:"schemaVersion"` - MediaType string `json:"mediaType"` - Config descriptor `json:"config"` - Layers []descriptor `json:"layers"` -} - -// Holder is the manifest that holds an archive in the store, and its digest. -// -// A pure function of the archive's digest and size: the same two in give the same bytes out, -// always, because `encoding/json` writes a struct's fields in their declared order and there is -// nothing here that varies by when or where it was composed. -func Holder(digest string, size int64) (body []byte, holder string) { - body, err := json.Marshal(holderManifest{ - SchemaVersion: 2, - MediaType: mediaManifest, - Config: descriptor{MediaType: mediaEmpty, Digest: emptyDigest, Size: int64(len(emptyConfig))}, - Layers: []descriptor{{MediaType: mediaArchive, Digest: digest, Size: size}}, - }) - if err != nil { - // Marshalling a struct of strings and integers cannot fail. - panic(err) - } - sum := sha256.Sum256(body) - return body, "sha256:" + hex.EncodeToString(sum[:]) -} - -// Hold makes sure the store holds this archive by a manifest, and says whether it had to write one. -// -// Takes a reference as the mesh records it. An image is its own manifest and needs no holder, so -// it answers false and nothing is asked. Idempotent: a holder already there is left alone, which -// is what lets the sweep run it over every kept archive on every build and so backfill the bare -// blobs published before holders existed (novox/hq issue 253). -// -// Gone when the store does not hold the archive at all: there is nothing to hold, and that is a -// fact the caller reports rather than one this invents a remedy for. -func (s Store) Hold(ctx context.Context, reference string) (bool, error) { - repository, digest, archive, err := s.archive(reference) - if err != nil || !archive { - return false, err - } - size, err := s.blobSize(ctx, repository, digest) - if err != nil { - return false, err - } - return s.HoldBlob(ctx, repository, digest, size) -} - -// Held is whether the store holds this archive by its manifest. Asks and changes nothing — the -// question an operator needs answered with "none unheld" before the collector is let loose. -// -// An image answers true: it is its own manifest. An archive the store does not have answers Gone. -func (s Store) Held(ctx context.Context, reference string) (bool, error) { - repository, digest, archive, err := s.archive(reference) - if err != nil { - return false, err - } - if !archive { - return true, nil - } - size, err := s.blobSize(ctx, repository, digest) - if err != nil { - return false, err - } - _, holder := Holder(digest, size) - return s.has(ctx, s.url(repository, "manifests", holder), manifestAccept...) -} - -// HoldBlob puts the holder for a blob of this digest and size into its repository, unless it is -// there already. Answers whether it wrote one. -// -// The builder calls this with the size it has just uploaded; the sweep, through Hold, with the size -// the store reports. Both arrive at the same holder, which is the point of composing it. -func (s Store) HoldBlob(ctx context.Context, repository, digest string, size int64) (bool, error) { - if s.Address == "" { - return false, fmt.Errorf("this mesh has no artifact store on its network to hold %s/%s in", repository, digest) - } - body, holder := Holder(digest, size) - there, err := s.has(ctx, s.url(repository, "manifests", holder), manifestAccept...) - if err != nil { - return false, err - } - if there { - return false, nil - } - - // The config must be in the repository before a manifest naming it is accepted: a registry - // refuses a manifest whose blobs it cannot find there, which is the property that makes a - // holder mean something. - if err := s.putBlob(ctx, repository, emptyDigest, emptyConfig); err != nil { - return false, err - } - - // **By digest, never by tag.** A tag would be one more name to move and one more thing the - // collector's `--delete-untagged` would read as meaningful; the mesh names nothing by tag that - // it pins by digest, and an untagged manifest is kept by plain collection. - request, err := http.NewRequestWithContext(ctx, http.MethodPut, - s.url(repository, "manifests", holder), bytes.NewReader(body)) - if err != nil { - return false, err - } - request.Header.Set("Content-Type", mediaManifest) - response, err := s.client().Do(request) - if err != nil { - return false, err - } - defer response.Body.Close() - if response.StatusCode != http.StatusCreated { - said, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) - return false, fmt.Errorf("the artifact store refused to hold %s/%s: %s %s", - repository, digest, response.Status, strings.TrimSpace(string(said))) - } - return true, nil -} - -// letGoOfHolder deletes the manifest holding an archive, before the archive's own link goes. -// -// **Holder first.** Deleting the blob link alone leaves a manifest still naming the blob, and the -// collector would keep its bytes for ever on the strength of it — the sweep would record the -// archive collected while the disk said otherwise. Deleting the holder first and failing before -// the link goes leaves an unheld archive that the next sweep still offers, which is safe. -// -// A store that no longer has the blob answers Gone: without its size the holder cannot be named, -// and without the blob there is nothing left for a holder to keep. A store that never had a holder -// for it — an archive published before holders, never backfilled — answers 404 to the delete, and -// that is the outcome wanted. -func (s Store) letGoOfHolder(ctx context.Context, repository, digest string) error { - size, err := s.blobSize(ctx, repository, digest) - if err != nil { - return err - } - _, holder := Holder(digest, size) - err = s.remove(ctx, s.url(repository, "manifests", holder), repository+"/manifests/"+holder) - if err == Gone { - return nil - } - return err -} - -// archive reads a recorded reference into its repository and digest, and whether it is an archive -// at all. Refuses as ErrNotOurs anything the mesh did not put in its own store. -func (s Store) archive(reference string) (repository, digest string, archive bool, err error) { - path, kept := catalogue.InArtifactStore(reference) - if !kept { - return "", "", false, fmt.Errorf("%w: %s", ErrNotOurs, reference) - } - if s.Address == "" { - return "", "", false, fmt.Errorf("this mesh has no artifact store on its network to ask about %s", reference) - } - repository, kind, digest, err := split(path) - if err != nil { - return "", "", false, err - } - return repository, digest, kind == "blobs", nil -} - -// blobSize is how large the store says a blob is; Gone when it does not have it. -func (s Store) blobSize(ctx context.Context, repository, digest string) (int64, error) { - request, err := http.NewRequestWithContext(ctx, http.MethodHead, s.url(repository, "blobs", digest), nil) - if err != nil { - return 0, err - } - response, err := s.client().Do(request) - if err != nil { - return 0, fmt.Errorf("cannot reach the artifact store at %s: %w", s.Address, err) - } - defer response.Body.Close() - switch response.StatusCode { - case http.StatusOK: - case http.StatusNotFound: - return 0, Gone - default: - return 0, fmt.Errorf("the artifact store answered %s for %s/blobs/%s", response.Status, repository, digest) - } - // Read from the header rather than ContentLength: a HEAD's ContentLength is what the response - // says it would have sent, which Go reports faithfully, but a proxy in between is free to drop - // it, and the header is what the registry itself wrote. - if length := response.Header.Get("Content-Length"); length != "" { - if n, err := strconv.ParseInt(length, 10, 64); err == nil && n >= 0 { - return n, nil - } - } - if response.ContentLength >= 0 { - return response.ContentLength, nil - } - return 0, fmt.Errorf("the artifact store holds %s/blobs/%s and will not say how large it is", repository, digest) -} - -// putBlob uploads a small blob unless the repository already has it: ask where, then put it there -// naming the digest — the registry's own two steps, the same the builder takes for an archive. -func (s Store) putBlob(ctx context.Context, repository, digest string, body []byte) error { - if there, err := s.has(ctx, s.url(repository, "blobs", digest)); err != nil { - return err - } else if there { - return nil - } - start, err := http.NewRequestWithContext(ctx, http.MethodPost, - "http://"+s.Address+"/v2/"+repository+"/blobs/uploads/", nil) - if err != nil { - return err - } - begun, err := s.client().Do(start) - if err != nil { - return fmt.Errorf("cannot start an upload to %s: %w", repository, err) - } - begun.Body.Close() - if begun.StatusCode != http.StatusAccepted { - return fmt.Errorf("the artifact store answered %s when asked where to put a blob in %s", begun.Status, repository) - } - where := begun.Header.Get("Location") - if where == "" { - return fmt.Errorf("the artifact store accepted an upload to %s and said nowhere to put it", repository) - } - if strings.HasPrefix(where, "/") { - where = "http://" + s.Address + where - } - separator := "?" - if strings.Contains(where, "?") { - separator = "&" - } - put, err := http.NewRequestWithContext(ctx, http.MethodPut, where+separator+"digest="+digest, bytes.NewReader(body)) - if err != nil { - return err - } - put.Header.Set("Content-Type", "application/octet-stream") - done, err := s.client().Do(put) - if err != nil { - return err - } - defer done.Body.Close() - if done.StatusCode != http.StatusCreated { - said, _ := io.ReadAll(io.LimitReader(done.Body, 4096)) - return fmt.Errorf("the artifact store refused a blob in %s: %s %s", repository, done.Status, strings.TrimSpace(string(said))) - } - return nil -} - -// has is whether the store answers 200 for a HEAD at that URL. -func (s Store) has(ctx context.Context, url string, accept ...string) (bool, error) { - request, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) - if err != nil { - return false, err - } - for _, media := range accept { - request.Header.Add("Accept", media) - } - response, err := s.client().Do(request) - if err != nil { - return false, fmt.Errorf("cannot reach the artifact store at %s: %w", s.Address, err) - } - defer response.Body.Close() - switch response.StatusCode { - case http.StatusOK: - return true, nil - case http.StatusNotFound: - return false, nil - default: - return false, fmt.Errorf("the artifact store answered %s for %s", response.Status, url) - } -} - -func (s Store) url(repository, kind, digest string) string { - return "http://" + s.Address + "/v2/" + repository + "/" + kind + "/" + digest -} - -func (s Store) client() *http.Client { - if s.HTTP != nil { - return s.HTTP - } - return &http.Client{Timeout: 30 * time.Second} -} diff --git a/modules/artifact-store-tools/internal/registry/hold_test.go b/modules/artifact-store-tools/internal/registry/hold_test.go deleted file mode 100644 index 8a3a16fa3..000000000 --- a/modules/artifact-store-tools/internal/registry/hold_test.go +++ /dev/null @@ -1,268 +0,0 @@ -package registry - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/http/httptest" - "strconv" - "strings" - "sync" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// Every kept archive is held by a manifest (novox/hq issue 253, ADR 0189). -// -// Against an in-memory registry that keeps blobs and manifests per repository and refuses what a -// registry refuses — a blob whose digest does not match, a manifest whose digest does not match -// or whose blobs the repository does not have, a manifest asked for without an Accept naming its -// type. What is asserted is this side's decisions; the live test below asserts the registry's. - -type memRegistry struct { - mu sync.Mutex - blobs map[string][]byte // repository + "@" + digest - manifests map[string][]byte // repository + "@" + digest - writes []string // every PUT and DELETE, as "METHOD path" -} - -func digestOf(body []byte) string { - sum := sha256.Sum256(body) - return "sha256:" + hex.EncodeToString(sum[:]) -} - -func (m *memRegistry) serve(t *testing.T) Store { - t.Helper() - m.blobs = map[string][]byte{} - m.manifests = map[string][]byte{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - m.mu.Lock() - defer m.mu.Unlock() - path := strings.TrimPrefix(r.URL.Path, "/v2/") - if r.Method == http.MethodPut || r.Method == http.MethodDelete { - m.writes = append(m.writes, r.Method+" "+r.URL.Path) - } - switch { - case r.Method == http.MethodPost && strings.HasSuffix(path, "/blobs/uploads/"): - repository := strings.TrimSuffix(path, "/blobs/uploads/") - w.Header().Set("Location", "/upload/"+repository+"?state=x") - w.WriteHeader(http.StatusAccepted) - case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/upload/"): - repository := strings.TrimPrefix(r.URL.Path, "/upload/") - body, _ := io.ReadAll(r.Body) - digest := r.URL.Query().Get("digest") - if digest != digestOf(body) { - w.WriteHeader(http.StatusBadRequest) - return - } - m.blobs[repository+"@"+digest] = body - w.WriteHeader(http.StatusCreated) - case strings.Contains(path, "/blobs/"): - repository, digest, _ := strings.Cut(path, "/blobs/") - key := repository + "@" + digest - body, ok := m.blobs[key] - if !ok { - w.WriteHeader(http.StatusNotFound) - return - } - switch r.Method { - case http.MethodHead: - w.Header().Set("Content-Length", strconv.Itoa(len(body))) - w.WriteHeader(http.StatusOK) - case http.MethodDelete: - delete(m.blobs, key) - w.WriteHeader(http.StatusAccepted) - default: - w.WriteHeader(http.StatusMethodNotAllowed) - } - case strings.Contains(path, "/manifests/"): - repository, digest, _ := strings.Cut(path, "/manifests/") - key := repository + "@" + digest - switch r.Method { - case http.MethodHead: - if _, ok := m.manifests[key]; !ok || !strings.Contains(r.Header.Get("Accept"), mediaManifest) { - w.WriteHeader(http.StatusNotFound) - return - } - w.WriteHeader(http.StatusOK) - case http.MethodPut: - body, _ := io.ReadAll(r.Body) - if digest != digestOf(body) { - w.WriteHeader(http.StatusBadRequest) - return - } - var named holderManifest - if err := json.Unmarshal(body, &named); err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - for _, d := range append([]descriptor{named.Config}, named.Layers...) { - if _, ok := m.blobs[repository+"@"+d.Digest]; !ok { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprintf(w, "MANIFEST_BLOB_UNKNOWN %s", d.Digest) - return - } - } - m.manifests[key] = body - w.WriteHeader(http.StatusCreated) - case http.MethodDelete: - if _, ok := m.manifests[key]; !ok { - w.WriteHeader(http.StatusNotFound) - return - } - delete(m.manifests, key) - w.WriteHeader(http.StatusAccepted) - } - default: - w.WriteHeader(http.StatusNotFound) - } - })) - t.Cleanup(server.Close) - return Store{Address: strings.TrimPrefix(server.URL, "http://")} -} - -// bare puts an archive in the store the way the builder did before holders: a blob, nothing more. -func (m *memRegistry) bare(repository string, body []byte) string { - m.mu.Lock() - defer m.mu.Unlock() - digest := digestOf(body) - m.blobs[repository+"@"+digest] = body - return catalogue.ArtifactStoreScheme + repository + "/blobs/" + digest -} - -func TestTheHolderIsComposedFromTheDigestAndSizeAlone(t *testing.T) { - // The sweep must arrive at the very manifest the builder wrote, with nothing recorded between - // them. Same inputs, same bytes — and a different size is a different holder, so a holder can - // never be mistaken for one of a different blob. - digest := "sha256:" + strings.Repeat("a", 64) - one, first := Holder(digest, 42) - two, second := Holder(digest, 42) - if !bytes.Equal(one, two) || first != second { - t.Fatalf("the same archive composed two holders:\n%s\n%s", one, two) - } - if _, other := Holder(digest, 43); other == first { - t.Fatal("a different size composed the same holder") - } - want := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json",` + - `"config":{"mediaType":"application/vnd.oci.empty.v1+json",` + - `"digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2},` + - `"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"` + digest + `","size":42}]}` - if string(one) != want { - t.Fatalf("the holder is\n%s\nwant\n%s", one, want) - } - if digestOf(emptyConfig) != emptyDigest { - t.Fatalf("the empty config's digest is %s, not %s", digestOf(emptyConfig), emptyDigest) - } -} - -func TestHoldBackfillsABareArchiveAndIsIdempotent(t *testing.T) { - // The archives published before this have no holder. Hold, run over every kept archive on - // every sweep, writes one the first time and nothing after. - m := &memRegistry{} - store := m.serve(t) - ctx := context.Background() - body := []byte("a theme") - reference := m.bare("shell/config", body) - - if held, err := store.Held(ctx, reference); err != nil || held { - t.Fatalf("a bare blob reads as held=%v (%v)", held, err) - } - wrote, err := store.Hold(ctx, reference) - if err != nil { - t.Fatal(err) - } - if !wrote { - t.Fatal("holding a bare archive wrote nothing") - } - _, holder := Holder(digestOf(body), int64(len(body))) - if _, ok := m.manifests["shell/config@"+holder]; !ok { - t.Fatalf("the store holds manifests %v; want %s", m.manifests, holder) - } - if held, err := store.Held(ctx, reference); err != nil || !held { - t.Fatalf("after holding, held=%v (%v)", held, err) - } - - writes := len(m.writes) - wrote, err = store.Hold(ctx, reference) - if err != nil { - t.Fatal(err) - } - if wrote || len(m.writes) != writes { - t.Fatalf("holding again wrote %v", m.writes[writes:]) - } -} - -func TestAnImageNeedsNoHolderAndAMissingArchiveIsGone(t *testing.T) { - m := &memRegistry{} - store := m.serve(t) - ctx := context.Background() - - // An image is its own manifest: nothing is asked. - wrote, err := store.Hold(ctx, catalogue.ArtifactStoreScheme+"web/app@sha256:"+strings.Repeat("b", 64)) - if err != nil || wrote || len(m.writes) != 0 { - t.Fatalf("holding an image wrote=%v err=%v writes=%v", wrote, err, m.writes) - } - // An archive the store does not have is a fact to report, not something to invent a holder for. - _, err = store.Hold(ctx, catalogue.ArtifactStoreScheme+"web/config/blobs/sha256:"+strings.Repeat("c", 64)) - if !errors.Is(err, Gone) { - t.Fatalf("holding a missing archive answered %v, want Gone", err) - } - // And a reference that is not the mesh's is refused as such. - if _, err := store.Hold(ctx, "docker.io/library/registry@sha256:abc"); !errors.Is(err, ErrNotOurs) { - t.Fatalf("holding a vendor's image answered %v, want ErrNotOurs", err) - } -} - -func TestLettingGoOfAnArchiveDeletesItsHolderFirst(t *testing.T) { - // A holder left behind would keep the bytes through every collection while the record said - // collected; the link deleted first and the holder failing after would be that exactly. - m := &memRegistry{} - store := m.serve(t) - ctx := context.Background() - body := []byte("an old theme") - reference := m.bare("shell/config", body) - if _, err := store.Hold(ctx, reference); err != nil { - t.Fatal(err) - } - m.writes = nil - - if err := store.LetGo(ctx, reference); err != nil { - t.Fatal(err) - } - _, holder := Holder(digestOf(body), int64(len(body))) - want := []string{ - "DELETE /v2/shell/config/manifests/" + holder, - "DELETE /v2/shell/config/blobs/" + digestOf(body), - } - if strings.Join(m.writes, "\n") != strings.Join(want, "\n") { - t.Fatalf("the store was asked\n%s\nwant\n%s", strings.Join(m.writes, "\n"), strings.Join(want, "\n")) - } - if len(m.manifests) != 0 { - t.Fatalf("a holder survived: %v", m.manifests) - } - // Asked again, the archive is already gone, which is the outcome wanted. - if err := store.LetGo(ctx, reference); !errors.Is(err, Gone) { - t.Fatalf("letting go twice answered %v, want Gone", err) - } -} - -func TestLettingGoOfAnUnheldArchiveStillDeletesIt(t *testing.T) { - // An archive published before holders and let go of before any sweep held it: the holder's - // delete answers 404, which is the outcome wanted, and the blob still goes. - m := &memRegistry{} - store := m.serve(t) - reference := m.bare("shell/config", []byte("never held")) - if err := store.LetGo(context.Background(), reference); err != nil { - t.Fatal(err) - } - if len(m.blobs) != 0 { - t.Fatalf("the blob survived: %v", m.blobs) - } -} diff --git a/modules/artifact-store-tools/internal/registry/platforms_test.go b/modules/artifact-store-tools/internal/registry/platforms_test.go deleted file mode 100644 index a335fb2cd..000000000 --- a/modules/artifact-store-tools/internal/registry/platforms_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package registry - -import ( - "context" - "errors" - "fmt" - "net/http" - "net/http/httptest" - "slices" - "strings" - "sync" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// An index is let go of with the platform manifests it names, except those a kept index of the same -// repository names too (novox/hq ADR 0257). Each platform manifest is a manifest of the repository in -// its own right, and the store's collector keeps every manifest a repository holds — so an index let go -// of alone frees nothing of the images it names. - -func digestN(n int) string { return fmt.Sprintf("sha256:%064x", n) } - -// indexStore holds indexes and images in one repository, answers GETs with their documents, and records -// every delete. -type indexStore struct { - mu sync.Mutex - indexes map[string][]string // digest → the platform manifests it names - images map[string]bool - deleted []string -} - -func (s *indexStore) serve(t *testing.T) Store { - t.Helper() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - s.mu.Lock() - defer s.mu.Unlock() - digest := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:] - switch r.Method { - case http.MethodGet: - if children, ok := s.indexes[digest]; ok { - var named []string - for _, c := range children { - named = append(named, `{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"`+c+`","size":1}`) - } - w.Header().Set("Content-Type", "application/vnd.oci.image.index.v1+json") - _, _ = w.Write([]byte(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[` + - strings.Join(named, ",") + `]}`)) - return - } - if s.images[digest] { - w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") - _, _ = w.Write([]byte(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","layers":[]}`)) - return - } - w.WriteHeader(http.StatusNotFound) - case http.MethodHead: - if _, ok := s.indexes[digest]; ok || s.images[digest] { - w.WriteHeader(http.StatusOK) - return - } - w.WriteHeader(http.StatusNotFound) - case http.MethodDelete: - if _, ok := s.indexes[digest]; !ok && !s.images[digest] { - w.WriteHeader(http.StatusNotFound) - return - } - s.deleted = append(s.deleted, digest) - delete(s.indexes, digest) - delete(s.images, digest) - w.WriteHeader(http.StatusAccepted) - default: - w.WriteHeader(http.StatusBadRequest) - } - })) - t.Cleanup(server.Close) - return Store{Address: strings.TrimPrefix(server.URL, "http://")} -} - -func TestAnIndexGoesWithItsPlatformsAndAKeptIndexKeepsItsOwn(t *testing.T) { - // Two indexes of one image's repository: the old one names platforms 11 and 12, the kept one 12 and 13. - s := &indexStore{ - indexes: map[string][]string{digestN(1): {digestN(11), digestN(12)}, digestN(2): {digestN(12), digestN(13)}}, - images: map[string]bool{digestN(11): true, digestN(12): true, digestN(13): true}, - } - store := s.serve(t) - repository := "upstream/docker.io/library/golang" - old := catalogue.ArtifactStoreScheme + repository + "@" + digestN(1) - kept := catalogue.ArtifactStoreScheme + repository + "@" + digestN(2) - spare, err := store.SpareKeptIndexes(context.Background(), []string{kept}, []string{old}) - if err != nil { - t.Fatal(err) - } - store.Spare = spare - - if err := store.LetGo(context.Background(), old); err != nil { - t.Fatal(err) - } - // Its own platform first, the index last; the platform the kept index names is left in place. - if !slices.Equal(s.deleted, []string{digestN(11), digestN(1)}) { - t.Fatalf("deleted %v; want the old index's own platform, then the index", s.deleted) - } - if !s.images[digestN(12)] || !s.images[digestN(13)] { - t.Fatal("a platform a kept index names was let go of") - } -} - -func TestWithoutSparingAnIndexGoesAloneAndAnImageNamesNoPlatforms(t *testing.T) { - s := &indexStore{indexes: map[string][]string{digestN(1): {digestN(11)}}, images: map[string]bool{digestN(11): true, digestN(5): true}} - store := s.serve(t) - // No Spare: as before ADR 0257, the index alone. - if err := store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/base@"+digestN(1)); err != nil { - t.Fatal(err) - } - if !slices.Equal(s.deleted, []string{digestN(1)}) { - t.Fatalf("without a Spare, deleted %v", s.deleted) - } - // An image is one delete, with a Spare or without. - s.deleted = nil - spare, err := store.SpareKeptIndexes(context.Background(), nil, []string{catalogue.ArtifactStoreScheme + "web/app@" + digestN(5)}) - if err != nil { - t.Fatal(err) - } - store.Spare = spare - if err := store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/app@"+digestN(5)); err != nil { - t.Fatal(err) - } - if !slices.Equal(s.deleted, []string{digestN(5)}) { - t.Fatalf("an image was let go of as %v", s.deleted) - } - // An index the store no longer holds is Gone, and nothing is deleted on its account. - s.deleted = nil - err = store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/base@"+digestN(9)) - if !errors.Is(err, Gone) || len(s.deleted) != 0 { - t.Fatalf("an index the store does not hold: %v, deleted %v", err, s.deleted) - } -} - -func TestASpareThatCannotBeReadKeepsTheIndex(t *testing.T) { - s := &indexStore{indexes: map[string][]string{digestN(1): {digestN(11)}}, images: map[string]bool{digestN(11): true}} - store := s.serve(t) - store.Spare = func(context.Context, string) (map[string]bool, error) { return nil, fmt.Errorf("the store went away") } - err := store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/base@"+digestN(1)) - if err == nil || !strings.Contains(err.Error(), "was kept") { - t.Fatalf("a spare that could not be read: %v", err) - } - if len(s.deleted) != 0 { - t.Fatalf("deleted %v without knowing what a kept index names", s.deleted) - } -} - -// The spare list is read whole before the sweep: a kept index that cannot be read is an error before -// anything is deleted, and a repository not read before keeps its index. -func TestASpareListIsReadWholeBeforeAnythingGoes(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - t.Cleanup(server.Close) - store := Store{Address: strings.TrimPrefix(server.URL, "http://")} - kept := catalogue.ArtifactStoreScheme + "web/base@" + digestN(2) - old := catalogue.ArtifactStoreScheme + "web/base@" + digestN(1) - if _, err := store.SpareKeptIndexes(context.Background(), []string{kept}, []string{old}); err == nil { - t.Fatal("a kept index the store would not answer for was taken as naming nothing") - } - - s := &indexStore{indexes: map[string][]string{digestN(1): {digestN(11)}}, images: map[string]bool{digestN(11): true}} - good := s.serve(t) - spare, err := good.SpareKeptIndexes(context.Background(), nil, []string{catalogue.ArtifactStoreScheme + "other/repo@" + digestN(3)}) - if err != nil { - t.Fatal(err) - } - good.Spare = spare - if err := good.LetGo(context.Background(), old); err == nil || len(s.deleted) != 0 { - t.Fatalf("an index in a repository not read before the sweep was let go of: %v, deleted %v", err, s.deleted) - } -} diff --git a/modules/artifact-store-tools/internal/registry/store.go b/modules/artifact-store-tools/internal/registry/store.go deleted file mode 100644 index 8a1fe931a..000000000 --- a/modules/artifact-store-tools/internal/registry/store.go +++ /dev/null @@ -1,325 +0,0 @@ -// Package registry speaks to the mesh's artifact store over its own door. -// -// Only what the mesh needs that nothing else does: letting go of something it put there -// (novox/hq ADR 0189, issue 108), and holding every archive it keeps by a manifest so the store's -// own collector does not take it (novox/hq issue 253). Pushing is the builder's, through the container runtime; reading -// is every machine's, through its runtime. This is the one operation that belongs to the thing -// holding the records, because it is the only one that is a decision rather than a transfer. -// -// Copied from mesh-controller internal/artifacts at e04846e9 (novox/hq ADR 0571 clause 2): the seat mesh-artifact-sweep lets -// go of what it no longer keeps exactly as the controller's sweep did; the controller's copy goes with its lease. -package registry - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// Store is the artifact store at an address, as this machine reaches it. -type Store struct { - // Address is `host:port` — the store as the caller reaches it now, composed and never - // recorded (novox/hq 04-ISSUES/102). - Address string - // HTTP is the client used; nil is a client with a modest timeout. - HTTP *http.Client - // Spare says, for one repository, which manifests a kept index there names: the platform - // manifests an index being let go of must leave in place (novox/hq ADR 0257). Nil spares every - // platform manifest — an index is then let go of alone, as it was before. - Spare func(ctx context.Context, repository string) (map[string]bool, error) -} - -// Gone is the answer when the store does not hold it: the outcome wanted, already true. -var Gone = errors.New("the store does not hold it") - -// ErrNotOurs is a reference this sweep will not address: not the mesh's own, or naming nothing -// the store holds by digest. -// -// **A fact about the record, not about the store** (novox/hq issue 226). The two deserve opposite -// responses — skip one and go on, abandon the sweep for the other — and collapsing them into "an -// error" is how a cautious loop became one that did nothing while reporting the right number. -var ErrNotOurs = errors.New("not a reference into the mesh's artifact store") - -// LetGo asks the store to drop one artifact the mesh recorded making. -// -// Takes a reference as the mesh records it — `artifact-store:///@sha256:…` for -// an image, `…/blobs/sha256:…` for an archive — because that is the identity every record uses, -// and composes the address here at the moment of use. -// -// An archive is let go of in two deletes, its holder manifest and then the blob's link (novox/hq -// issue 253); an image in one. -// -// Returns Gone when the store answers that it does not have it. That is not a failure: the sweep -// wants the artifact absent, and it is. It is distinguished from success only so a caller can say -// which of the two happened. -func (s Store) LetGo(ctx context.Context, reference string) error { - // **Strict, and deliberately** (novox/hq issue 226). Only a reference the mesh keeps in its - // own vocabulary is addressed here. `Recorded` would read `docker.io/library/registry@sha256:…` - // as the mesh's too — it cannot tell one registry host from another — so normalising belongs - // where the provenance is known, which is the sweep reading its own build records, not here - // where the only job is to refuse anything that is not plainly ours. - path, kept := catalogue.InArtifactStore(reference) - if !kept { - // Nothing the mesh put in its own store. Refused rather than attempted: composing a - // delete for a reference of unknown shape is how a sweep reaches something that is not - // the mesh's. Distinguished from a store that refuses, so a sweep skips this and goes on. - return fmt.Errorf("%w: %s", ErrNotOurs, reference) - } - if s.Address == "" { - return fmt.Errorf("this mesh has no artifact store on its network to ask about %s", reference) - } - repository, kind, digest, err := split(path) - if err != nil { - return err - } - if kind == "blobs" { - // **An archive's holder goes before the archive** (novox/hq issue 253): a manifest left - // naming the blob would keep its bytes through every collection while the record said - // collected. Gone here means the store has no such blob, so there is nothing to let go. - if err := s.letGoOfHolder(ctx, repository, digest); err != nil { - return err - } - } - if kind == "manifests" && s.Spare != nil { - // **An index's platform manifests go before the index** (novox/hq ADR 0257). Each was put - // under its own digest, so each is a manifest of the repository in its own right, and the - // store's collector keeps every manifest a repository holds: an index let go of alone frees - // no byte of the images it names. Before, not after, so a sweep cut short leaves the index - // to be asked about again, and with it the list of what is still to go. - if err := s.letGoOfPlatforms(ctx, repository, digest, reference); err != nil { - return err - } - } - return s.remove(ctx, s.url(repository, kind, digest), reference) -} - -// letGoOfPlatforms deletes the manifests an index names, except those a kept index of the same -// repository also names. A manifest that is not an index names none. -func (s Store) letGoOfPlatforms(ctx context.Context, repository, digest, reference string) error { - children, err := s.Platforms(ctx, repository, digest) - if err != nil || len(children) == 0 { - // Gone: there is no index to read, and the delete that follows says so. - if errors.Is(err, Gone) { - return nil - } - return err - } - spared, err := s.Spare(ctx, repository) - if err != nil { - return fmt.Errorf("cannot say which platform manifests of %s a kept index names, so %s was kept: %w", - repository, reference, err) - } - for _, child := range children { - if spared[child] { - continue - } - if err := s.remove(ctx, s.url(repository, "manifests", child), reference+" (its "+child+")"); err != nil && !errors.Is(err, Gone) { - return err - } - } - return nil -} - -// anyManifest is every manifest the store may hold, indexes included, so it answers with the -// document as it is. -var anyManifest = []string{ - "application/vnd.oci.image.index.v1+json", - "application/vnd.docker.distribution.manifest.list.v2+json", - "application/vnd.oci.image.manifest.v1+json", - "application/vnd.docker.distribution.manifest.v2+json", -} - -// Platforms is the manifests an index names, by digest; none for a manifest that is not an index. -// Gone when the store does not hold it. -func (s Store) Platforms(ctx context.Context, repository, digest string) ([]string, error) { - request, err := http.NewRequestWithContext(ctx, http.MethodGet, s.url(repository, "manifests", digest), nil) - if err != nil { - return nil, err - } - request.Header.Set("Accept", strings.Join(anyManifest, ", ")) - response, err := s.client().Do(request) - if err != nil { - return nil, err - } - defer response.Body.Close() - switch response.StatusCode { - case http.StatusOK: - case http.StatusNotFound: - return nil, Gone - default: - return nil, fmt.Errorf("the artifact store answered %s when asked for %s@%s", response.Status, repository, digest) - } - var document struct { - Manifests []struct { - Digest string `json:"digest"` - } `json:"manifests"` - } - if err := json.NewDecoder(io.LimitReader(response.Body, 4<<20)).Decode(&document); err != nil { - return nil, fmt.Errorf("%s@%s is not a manifest: %w", repository, digest, err) - } - var out []string - for _, m := range document.Manifests { - if strings.HasPrefix(m.Digest, "sha256:") { - out = append(out, m.Digest) - } - } - return out, nil -} - -// SpareKeptIndexes is a Spare for a sweep, read whole before it starts: for every repository an -// image reference of `touching` is in, each kept manifest there and every platform manifest a kept -// index there names. Any read that fails is an error, and the sweep lets nothing go. A repository not -// read here is answered with an error, so an index in it is kept. A kept index the store no longer -// holds names nothing. -func (s Store) SpareKeptIndexes(ctx context.Context, kept, touching []string) (func(context.Context, string) (map[string]bool, error), error) { - imageIn := func(reference string) (repository, digest string, ok bool) { - path, ours := catalogue.InArtifactStore(reference) - if !ours { - return "", "", false - } - repository, kind, digest, err := split(path) - if err != nil || kind != "manifests" { - return "", "", false - } - return repository, digest, true - } - read := map[string]map[string]bool{} - for _, reference := range touching { - if repository, _, ok := imageIn(reference); ok { - read[repository] = map[string]bool{} - } - } - for _, reference := range kept { - repository, digest, ok := imageIn(reference) - spared, touched := read[repository] - if !ok || !touched { - continue - } - spared[digest] = true - children, err := s.Platforms(ctx, repository, digest) - if errors.Is(err, Gone) { - continue - } - if err != nil { - return nil, fmt.Errorf("reading the kept %s@%s: %w", repository, digest, err) - } - for _, c := range children { - spared[c] = true - } - } - return func(_ context.Context, repository string) (map[string]bool, error) { - spared, done := read[repository] - if !done { - return nil, fmt.Errorf("%s was not read before the sweep began", repository) - } - return spared, nil - }, nil -} - -// remove asks the store to delete what is at url. Gone when it has no such thing. -func (s Store) remove(ctx context.Context, url, what string) error { - request, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil) - if err != nil { - return err - } - response, err := s.client().Do(request) - if err != nil { - return err - } - defer response.Body.Close() - switch response.StatusCode { - case http.StatusAccepted, http.StatusOK, http.StatusNoContent: - return nil - case http.StatusNotFound: - return Gone - case http.StatusMethodNotAllowed: - // The registry was started without deletion enabled. Said plainly, because the remedy is - // a setting on the store's module and not anything about this artifact. - return fmt.Errorf( - "the artifact store refuses deletion: its server was started without it enabled "+ - "(REGISTRY_STORAGE_DELETE_ENABLED), so nothing can be collected until the store "+ - "module is applied again (novox/hq ADR 0189). Asking about %s", what) - default: - return fmt.Errorf("the artifact store answered %s for %s", response.Status, what) - } -} - -// split reads a recorded path into the repository, which endpoint names the thing, and the digest. -// -// Two shapes, which are the two the mesh records: `@sha256:` is a manifest, and -// `/blobs/sha256:` is a blob. -func split(path string) (repository, kind, digest string, err error) { - if before, after, ok := strings.Cut(path, "@sha256:"); ok { - return before, "manifests", "sha256:" + after, nil - } - if before, after, ok := strings.Cut(path, "/blobs/sha256:"); ok { - return before, "blobs", "sha256:" + after, nil - } - return "", "", "", fmt.Errorf("%w: %q names nothing the store holds by digest", ErrNotOurs, path) -} - -// IsIndex is whether a recorded reference is an index the store holds: one that names manifests. A -// blob, or a manifest that names none, is not. Gone when the store does not hold it. -func (s Store) IsIndex(ctx context.Context, reference string) (bool, error) { - path, ours := catalogue.InArtifactStore(reference) - if !ours { - return false, fmt.Errorf("%w: %s", ErrNotOurs, reference) - } - repository, kind, digest, err := split(path) - if err != nil || kind != "manifests" { - return false, err - } - children, err := s.Platforms(ctx, repository, digest) - return len(children) > 0, err -} - -// HoldsManifest is whether the store holds the manifest a recorded image reference names. -func (s Store) HoldsManifest(ctx context.Context, reference string) (bool, error) { - path, ours := catalogue.InArtifactStore(reference) - if !ours { - return false, fmt.Errorf("%w: %s", ErrNotOurs, reference) - } - repository, kind, digest, err := split(path) - if err != nil { - return false, err - } - if kind != "manifests" { - return false, fmt.Errorf("%w: %s names a blob, not a manifest", ErrNotOurs, reference) - } - return s.has(ctx, s.url(repository, kind, digest), anyManifest...) -} - -// Holds is whether the store holds what a recorded reference names: the blob of an archive, the manifest of an image. -// Gone when it does not; asks and changes nothing (ADR 0526 clause 3: a registration by hand is refused at bytes that -// are not there). -func (s Store) Holds(ctx context.Context, reference string) error { - path, ours := catalogue.InArtifactStore(reference) - if !ours { - return fmt.Errorf("%w: %s", ErrNotOurs, reference) - } - repository, kind, digest, err := split(path) - if err != nil { - return err - } - if s.Address == "" { - return fmt.Errorf("this mesh has no artifact store on its network to ask about %s", reference) - } - if kind == "blobs" { - _, err := s.blobSize(ctx, repository, digest) - return err - } - held, err := s.has(ctx, s.url(repository, kind, digest), anyManifest...) - if err != nil { - return err - } - if !held { - return Gone - } - return nil -} diff --git a/modules/artifact-store-tools/internal/registry/store_test.go b/modules/artifact-store-tools/internal/registry/store_test.go deleted file mode 100644 index 334b95a5a..000000000 --- a/modules/artifact-store-tools/internal/registry/store_test.go +++ /dev/null @@ -1,134 +0,0 @@ -package registry - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// Asking the store to let go of what the mesh no longer keeps (novox/hq ADR 0189, issue 108). -// -// A fake store records what it was asked to delete, so what is asserted is the mesh's decision -// and the shape of the request — not the registry's behaviour, which is the registry's to test. - -func fakeStore(t *testing.T, answer int) (Store, *[]string) { - t.Helper() - var asked []string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodHead && strings.Contains(r.URL.Path, "/blobs/") { - // An archive's size, asked so its holder can be named (novox/hq issue 253). A store - // that does not have the thing does not have its blob either. - if answer == http.StatusNotFound { - w.WriteHeader(http.StatusNotFound) - return - } - w.Header().Set("Content-Length", "7") - w.WriteHeader(http.StatusOK) - return - } - if r.Method != http.MethodDelete { - t.Errorf("the store was asked %s %s; collecting is a delete", r.Method, r.URL.Path) - } - asked = append(asked, r.URL.Path) - w.WriteHeader(answer) - })) - t.Cleanup(server.Close) - return Store{Address: strings.TrimPrefix(server.URL, "http://")}, &asked -} - -func TestAnImageAndAnArchiveAreAskedForAtTheirOwnEndpoints(t *testing.T) { - // The two shapes the mesh records: a manifest by digest, and a blob by digest. They are - // different endpoints, and asking at the wrong one answers 404 — which this would then - // record as collected, leaving the bytes on disk for ever while the record says otherwise. - store, asked := fakeStore(t, http.StatusAccepted) - ctx := context.Background() - - image := catalogue.ArtifactStoreScheme + "web/app@sha256:abc123" - archive := catalogue.ArtifactStoreScheme + "web/config/blobs/sha256:def456" - if err := store.LetGo(ctx, image); err != nil { - t.Fatal(err) - } - if err := store.LetGo(ctx, archive); err != nil { - t.Fatal(err) - } - // The archive's holder goes first, then the archive (novox/hq issue 253). - _, holder := Holder("sha256:def456", 7) - want := []string{ - "/v2/web/app/manifests/sha256:abc123", - "/v2/web/config/manifests/" + holder, - "/v2/web/config/blobs/sha256:def456", - } - if strings.Join(*asked, " ") != strings.Join(want, " ") { - t.Fatalf("the store was asked %v; want %v", *asked, want) - } -} - -func TestAStoreThatDoesNotHaveItAnswersGone(t *testing.T) { - // The outcome wanted, already true. Told apart from success only so the sweep can say which - // happened; both are recorded, because retrying for ever is the thing to avoid. - store, _ := fakeStore(t, http.StatusNotFound) - err := store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/app@sha256:abc123") - if !errors.Is(err, Gone) { - t.Fatalf("a store that does not hold it answered %v, want Gone", err) - } -} - -func TestAStoreWithDeletionOffSaysSoAndNamesTheRemedy(t *testing.T) { - // The registry answers 405 when it was started without deletion enabled. The remedy is a - // setting on the store's module, and saying "405" would send somebody to the wrong place. - store, _ := fakeStore(t, http.StatusMethodNotAllowed) - err := store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/app@sha256:abc123") - if err == nil { - t.Fatal("a store that refuses deletion was read as success") - } - if !strings.Contains(err.Error(), "REGISTRY_STORAGE_DELETE_ENABLED") { - t.Fatalf("the refusal does not name the remedy: %v", err) - } -} - -func TestAReferenceThatIsNotTheMeshsOwnIsNeverAsked(t *testing.T) { - // The whole safety of the sweep is that it names only what the mesh recorded putting there. - // A reference of another shape — a vendor's image, a package version — is refused rather - // than composed into a delete somewhere that is not the mesh's store. - store, asked := fakeStore(t, http.StatusAccepted) - for _, reference := range []string{ - "docker.io/library/registry@sha256:abc123", - "registry@sha256:abc123", - "1.4.2", - } { - if err := store.LetGo(context.Background(), reference); err == nil { - t.Errorf("%s was asked about; it is not a reference into the mesh's store", reference) - } - } - if len(*asked) != 0 { - t.Fatalf("the store was asked about %v", *asked) - } -} - -// A reference this sweep will not address says so as ErrNotOurs, which is a fact about the -// record and not about the store (novox/hq issue 226). -// -// The sweep skips one and abandons itself for the other, so they cannot be the same error. The -// first live run met a reference recorded with the store's old address, read the refusal as "the -// store refuses everything", and collected none of the 1681 it had found. -func TestAReferenceThisSweepWillNotAddressIsToldApartFromAStoreRefusing(t *testing.T) { - store, asked := fakeStore(t, http.StatusAccepted) - for _, reference := range []string{ - "docker.io/library/registry@sha256:abc123", - "127.0.0.1:5100/mesh-tools/build@sha256:abc123", - "1.4.2", - } { - err := store.LetGo(context.Background(), reference) - if !errors.Is(err, ErrNotOurs) { - t.Errorf("%s answered %v; a sweep must be able to skip it and go on", reference, err) - } - } - if len(*asked) != 0 { - t.Fatalf("the store was asked about %v", *asked) - } -} diff --git a/modules/artifact-store-tools/module.json b/modules/artifact-store-tools/module.json index 54cc59169..b1b4b1c6c 100644 --- a/modules/artifact-store-tools/module.json +++ b/modules/artifact-store-tools/module.json @@ -1,6 +1,9 @@ { "module": "artifact-store-tools", "version": "1", + "packages": { + "builds": "0.1" + }, "claims": [ { "name": "mesh-artifact-sweep", @@ -14,21 +17,30 @@ ] } ], - "requires": [ - "artifact-store" - ], "invokes": [ "seat:mesh-hand-acts.hand-act", "seat:mesh-catalog.modules", - "seat:mesh-catalog.module" + "seat:mesh-catalog.module", + "mesh-delivery-assigner.assigner_read_wants", + "mesh-delivery-indexer.indexer_read_manifests", + "seat:oci-artifact-registry.hold", + "seat:oci-artifact-registry.release", + "seat:oci-artifact-registry.remove", + "seat:container-image-registry.hold", + "seat:container-image-registry.release", + "seat:container-image-registry.remove", + "seat:container-image-registry.list", + "seat:oci-artifact-registry.list" ], "reads": [ - "mesh-delivery-assigner.wants", "mesh-delivery-builder.builds", "mesh-delivery-builder.build-log", - "mesh-delivery-indexer.manifests", "node-engine.have" ], + "consumes": [ + "mesh-delivery-assigner.views.wants", + "mesh-delivery-indexer.views.manifests" + ], "state": [ "findings" ], @@ -36,7 +48,9 @@ "artifact_store_repositories", "artifact_store_usage", "artifact_store_references", - "artifact_store_collect" + "artifact_store_collect", + "artifact_store_first_views", + "artifact_store_first_view_again" ], "config": { "older-store-names": { @@ -84,7 +98,6 @@ "artifact-store-tools" ], "env": { - "MESH_ARTIFACT_STORE_CONTAINER": "mesh-registry", "MESH_ARTIFACT_STORE_NAMES_FILE": "${dir:state}/store-names", "MESH_ARTIFACT_STORE_ADDRESS_FILE": "${dir:state}/store-address" } diff --git a/modules/asus-zephyrus-g14/go.mod b/modules/asus-zephyrus-g14/go.mod index a9d7eaf0e..b4fc5a234 100644 --- a/modules/asus-zephyrus-g14/go.mod +++ b/modules/asus-zephyrus-g14/go.mod @@ -2,10 +2,10 @@ module asuszephyrusg14 go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/asus-zephyrus-g14/go.sum b/modules/asus-zephyrus-g14/go.sum index 02d6daae7..0daef11ff 100644 --- a/modules/asus-zephyrus-g14/go.sum +++ b/modules/asus-zephyrus-g14/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/avahi/go.mod b/modules/avahi/go.mod index 49a0351ab..94991fb36 100644 --- a/modules/avahi/go.mod +++ b/modules/avahi/go.mod @@ -2,10 +2,10 @@ module avahi go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/avahi/go.sum b/modules/avahi/go.sum index 02d6daae7..0daef11ff 100644 --- a/modules/avahi/go.sum +++ b/modules/avahi/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/blueman/go.mod b/modules/blueman/go.mod index 3560b96c8..e860a4a16 100644 --- a/modules/blueman/go.mod +++ b/modules/blueman/go.mod @@ -2,10 +2,10 @@ module blueman go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/blueman/go.sum b/modules/blueman/go.sum index 02d6daae7..0daef11ff 100644 --- a/modules/blueman/go.sum +++ b/modules/blueman/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/bluetooth/go.mod b/modules/bluetooth/go.mod index b1929351f..13d51c0ef 100644 --- a/modules/bluetooth/go.mod +++ b/modules/bluetooth/go.mod @@ -2,10 +2,10 @@ module bluetooth go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/bluetooth/go.sum b/modules/bluetooth/go.sum index 02d6daae7..0daef11ff 100644 --- a/modules/bluetooth/go.sum +++ b/modules/bluetooth/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/brother/go.mod b/modules/brother/go.mod index 5a2a508db..6b81e546e 100644 --- a/modules/brother/go.mod +++ b/modules/brother/go.mod @@ -3,20 +3,20 @@ module brother go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) require ( - github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect + github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/minio/highwayhash v1.0.4 // indirect - github.com/nats-io/jwt/v2 v2.8.1 // indirect + github.com/nats-io/jwt/v2 v2.8.2 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect golang.org/x/crypto v0.57.0 // indirect golang.org/x/sys v0.48.0 // indirect - golang.org/x/time v0.15.0 // indirect + golang.org/x/time v0.16.0 // indirect ) diff --git a/modules/brother/go.sum b/modules/brother/go.sum index 43cc4ec77..8ca93dfd0 100644 --- a/modules/brother/go.sum +++ b/modules/brother/go.sum @@ -1,15 +1,13 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= -github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= -github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc= +github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc= github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= @@ -23,5 +21,5 @@ golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZ golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= diff --git a/modules/claude-code/README.md b/modules/claude-code/README.md index 27e576062..16dbccac0 100644 --- a/modules/claude-code/README.md +++ b/modules/claude-code/README.md @@ -122,7 +122,7 @@ The agent kept reaching for `ssh journalctl` while the service manager - **The "instead of" table.** Every seat verb and module tool may say which shell commands it replaces (`replaces`, in the seat's definition or the module's manifest). The module reads the bus — the seats' verbs from the seats' contract, the placed modules' manifests from `mesh-delivery-indexer.modules` and `.manifests`, the - machines and where each module runs from `mesh-delivery-assigner.placement` (issue 1126: the controller that + machines and where each module runs from `mesh-delivery-assigner.assignments` (issue 1126: the controller that answered this is not placed) — at start, every ten minutes and on `claude_code_render`, keeps it in its state (`mesh-tools.json`), and renders it into `CLAUDE.md` as one row per seat or module: `/.` and each verb with the commands it replaces. Generated, never written by hand: a verb that gains `replaces` is in @@ -206,10 +206,20 @@ judges the record as this module would have made it, composes the ask again from the text's digest, in time), and only then writes the section into its own `approved.json`, which the managed instruction file renders under "Instructions the operator approved". A proposal is decided once per node, and an approval older than the last change applied to its section is never applied; a record deleted from the bus removes nothing. Refused: shell lines, front matter, any `@` after a non-letter (a file the instruction file may read in), invisible or control characters, headings of level one to three, anything shaped like -a secret, more than 16 KiB, a name the terminal sets, and — at once, naming the terminal's line — whenever no channel +a secret, more than 16 KiB, and — at once, naming the terminal's line — whenever no channel that proves its sender is there. The controller's hand-act log records each warrant once (`warranted`), read from the router's record. The text is never logged. +**A section registered before is replaced or removed the same way** (hq issue 340, note 14): a section registered for the +mesh or the node before the controller's terminal took that over, or set there, is proposed over by name. The proposal +carries `over`, the digest of the registered text, which the act the operator's answer binds names; the ask says +plainly that it replaces (or removes) the registered section, with the registered text's fingerprint, and shows the new +text as any other. On Approve each node writes the section into `approved.json` with that `over`, and the managed +instruction file renders it in the place of the registered section (a removal leaves the section out); on Decline or at +expiry nothing changes. A replacement keeps the registered section's own heading form, because it replaces a mesh +section; the heading rule stays for every new shared section. `claude_code_proposals` shows each proposal's `registered +section` and `claude_code_registrations` lists what approvals replaced or removed. + **A session asks the operator a question on Telegram** (hq ADR 0332, issue 589). An agent that needs a decision while the operator may be away from the terminal calls `claude_code_ask` with a headline, the context in plain words, two to four options (a label and what choosing it means, exactly one recommended, with why), what it does if nobody diff --git a/modules/claude-code/cmd/claude-code/config.go b/modules/claude-code/cmd/claude-code/config.go index a2993df5d..194363d5f 100644 --- a/modules/claude-code/cmd/claude-code/config.go +++ b/modules/claude-code/cmd/claude-code/config.go @@ -637,6 +637,14 @@ func mergeSettings(a, b map[string]any) map[string]any { // registered for the mesh, then those for this node. func InstructionSections(c Config) string { var b strings.Builder + // The approved sections that replace or remove a registered one, by its name and the digest of its text, and the + // proposals already placed where the registered section stood. + over, placed := map[string]ApprovedSection{}, map[string]bool{} + for _, s := range c.Approved { + if s.Over != "" { + over[s.Name+"|"+s.Over] = s + } + } registered := "Registered through the `claude-code` module before that moved to the controller's terminal; change them there, in the module's `instructions` setting." for _, part := range []struct { title, note string @@ -654,14 +662,31 @@ func InstructionSections(c Config) string { } fmt.Fprintf(&b, "\n## %s\n\n%s\n", part.title, part.note) for _, it := range sections { - fmt.Fprintf(&b, "\n### %s\n\n%s\n", it.Name, strings.TrimSpace(it.Files[it.Name+".md"])) + text := it.Files[it.Name+".md"] + if s, ok := over[it.Name+"|"+textDigest(text)]; ok { + // What the operator approved over this registered section stands in its place, or leaves it out. + placed[s.Proposal] = true + if s.Removes { + continue + } + fmt.Fprintf(&b, "\n### %s\n\n*Replaced on the operator's approval (proposed from %s; approved %s).*\n\n%s\n", it.Name, s.From, + s.Approved.UTC().Format("2006-01-02 15:04 UTC"), strings.TrimSpace(s.Text)) + continue + } + fmt.Fprintf(&b, "\n### %s\n\n%s\n", it.Name, strings.TrimSpace(text)) } } - if len(c.Approved) > 0 { + var approved []ApprovedSection + for _, s := range c.Approved { + if !s.Removes && !placed[s.Proposal] { + approved = append(approved, s) + } + } + if len(approved) > 0 { b.WriteString("\n## Instructions the operator approved\n\nProposed by an agent through the `claude-code` module and " + "approved by the operator on a channel that proves who answered (novox/hq ADR 0274). Changed or removed only the " + "same way: `claude_code_instructions_propose`.\n") - for _, s := range c.Approved { + for _, s := range approved { fmt.Fprintf(&b, "\n### %s\n\n*Proposed from %s; approved %s.*\n\n%s\n", s.Name, s.From, s.Approved.UTC().Format("2006-01-02 15:04 UTC"), strings.TrimSpace(s.Text)) } diff --git a/modules/claude-code/cmd/claude-code/config_test.go b/modules/claude-code/cmd/claude-code/config_test.go index 73072d929..5cdbc2238 100644 --- a/modules/claude-code/cmd/claude-code/config_test.go +++ b/modules/claude-code/cmd/claude-code/config_test.go @@ -269,12 +269,18 @@ func TestTheManifestListsEveryToolServed(t *testing.T) { t.Fatal(err) } var m struct { - Tools []string `json:"tools"` - Invokes []string `json:"invokes"` + Tools []string `json:"tools"` + Invokes []string `json:"invokes"` + Reads []string `json:"reads"` + Consumes []string `json:"consumes"` } _ = json.Unmarshal(raw, &m) - if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted"}) { - t.Errorf("invokes = %v, want the hand-act seat's warranted alone", m.Invokes) + // The hand-act seat's warranted and hand-act, the seat mesh-catalog's seat list (ADR 0635 §1), and the owners' read tools of the three followed states; no verb of + // the controller, and no read of the indexer's or the assigner's state (ADR 0615 §1): their views are consumed. + if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", "seat:mesh-catalog.seat-definitions", ModulesTool, ManifestsTool, AssignmentsTool}) || + !slices.Equal(m.Reads, []string{"claude-licence-manager.bindings"}) || + !slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed"}) { + t.Errorf("invokes = %v, reads = %v, consumes = %v", m.Invokes, m.Reads, m.Consumes) } if src, _ := os.ReadFile("proposals.go"); !strings.Contains(string(src), `"seat:mesh-hand-acts.warranted"`) || strings.Contains(string(src), "seat:mesh-controller.warranted") { @@ -285,6 +291,7 @@ func TestTheManifestListsEveryToolServed(t *testing.T) { listed[n] = true } p, _ := node(t, "laptop") + meshViews = newMeshViews() served := tools(p, nil, NewServerView(p), memConfig{}, NewConfigView(p)) for _, tool := range served { if !listed[tool.Name] { diff --git a/modules/claude-code/cmd/claude-code/forge.go b/modules/claude-code/cmd/claude-code/forge.go index fda20225f..1990c0242 100644 --- a/modules/claude-code/cmd/claude-code/forge.go +++ b/modules/claude-code/cmd/claude-code/forge.go @@ -25,7 +25,8 @@ import ( "path/filepath" "strings" - "git.novox.be/novox/mesh-sdk/go/asks" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" ) // ForgeProvision is the provision whose credential this is, and the file this module's manifest has it delivered to. @@ -55,16 +56,16 @@ func (f *ForgeHealth) key() string { return "claude-code.machine." + f.Node + ". // Look is the condition to say after one placing of the credential, nil when nothing changed: raised on a failure, again // when its reason changes, cleared when it places again; on the first look a success clears what a run before raised. -func (f *ForgeHealth) Look(err error) *asks.Condition { +func (f *ForgeHealth) Look(err error) *oc.Condition { first := !f.looked f.looked = true - subject := asks.Scope{Scope: "machine", ID: f.Node, Machine: f.Node} + subject := oc.ConditionSubject{Scope: "machine", ID: f.Node, Machine: f.Node} if err == nil { if f.raised == "" && !first { return nil } f.raised = "" - return &asks.Condition{Key: f.key(), Subject: subject, State: asks.StateCleared} + return &oc.Condition{Key: f.key(), Subject: subject, State: occ.StateCleared} } why := err.Error() if why == f.raised { @@ -72,10 +73,10 @@ func (f *ForgeHealth) Look(err error) *asks.Condition { } f.raised = why needs := "read claude-code's journal on " + f.Node + " and the owner of its forge credential file" - return &asks.Condition{Key: f.key(), Severity: asks.SeverityWarning, State: asks.StateRaised, Subject: subject, + return &oc.Condition{Key: f.key(), Severity: occ.SeverityWarning, State: occ.StateRaised, Subject: subject, Headline: "Agents on " + f.Node + " cannot use the forge credential", Summary: why, Needs: needs, - Explanation: asks.NeedsYou + needs + ". " + sentence(why) + " Until it is placed, an agent there cannot " + + Explanation: occ.NeedsYou + needs + ". " + sentence(why) + " Until it is placed, an agent there cannot " + "clone or fetch the mesh's private repositories.", Resolved: "The forge's read credential is placed again on " + f.Node + "."} } diff --git a/modules/claude-code/cmd/claude-code/forge_test.go b/modules/claude-code/cmd/claude-code/forge_test.go index 551ad774d..a28e1a3ab 100644 --- a/modules/claude-code/cmd/claude-code/forge_test.go +++ b/modules/claude-code/cmd/claude-code/forge_test.go @@ -12,7 +12,7 @@ import ( "strings" "testing" - "git.novox.be/novox/mesh-sdk/go/asks" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" ) const agentToken = "0123456789abcdef0123456789abcdef01234567" @@ -316,7 +316,7 @@ func TestTheForgeCredentialIsTheRuntimesAndItsFailureIsAConditionOfTheModule(t * got := "" if c != nil { got = c.State - if _, err := asks.EncodeCondition(*c); err != nil { + if _, err := occ.EncodeCondition(*c); err != nil { t.Errorf("look %d: the condition is refused: %v", i, err) } if c.Key != "claude-code.machine.anchor.forge-credential" { diff --git a/modules/claude-code/cmd/claude-code/guard_test.go b/modules/claude-code/cmd/claude-code/guard_test.go index 5ea512ea8..aedd86b78 100644 --- a/modules/claude-code/cmd/claude-code/guard_test.go +++ b/modules/claude-code/cmd/claude-code/guard_test.go @@ -9,25 +9,25 @@ import ( "strings" "testing" - "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatdef" ) // meshForTheGuard is a mesh of two machines and the verbs that replace what an agent runs over ssh, as the // seats' contract and the modules' manifests say them. func meshForTheGuard() GuardData { - seats := []catalogue.Seat{ - {Name: "mesh-placement", Scope: catalogue.ScopeMesh, Serves: []catalogue.Verb{ - {Name: "placement", Description: "Read placement: one node's key.", Replaces: []string{"hostnamectl", "uptime"}}}}, - {Name: "node-service-manager", Scope: catalogue.ScopeNode, Serves: []catalogue.Verb{ + defined := []seatdef.Definition{ + {Seat: "mesh-module-assigner", Scope: "mesh", Verbs: []seatdef.Verb{ + {Name: "assignments", Description: "Read assignments: one node's key.", Replaces: []string{"hostnamectl", "uptime"}}}}, + {Seat: "node-service-manager", Scope: "node", Verbs: []seatdef.Verb{ {Name: "status", Description: "One unit as the service manager sees it now: its states.", Replaces: []string{"systemctl status", "systemctl is-active"}}, {Name: "restart", Description: "Restart one unit.", Replaces: []string{"systemctl restart"}}, {Name: "journal", Description: "The last lines of one unit's journal.", Replaces: []string{"journalctl"}}}}, - {Name: "node-hostname", Scope: catalogue.ScopeNode, Serves: []catalogue.Verb{ + {Seat: "node-hostname", Scope: "node", Verbs: []seatdef.Verb{ {Name: "add", Description: "Add one address and its names to the operator's lines of /etc/hosts.", Replaces: []string{"edit /etc/hosts", "HOSTALIASES"}}}}, } modules := []ModuleTools{{Module: "docker", On: []string{"anchor"}, Replaces: map[string][]string{"docker_logs": {"docker logs"}, "docker_list": {"docker ps"}}}, {Module: "systemd", On: []string{"anchor", "laptop"}}} - return GuardData{Node: "laptop", Replacements: ReplacementsOf(seats, modules), Machines: []Machine{ + return GuardData{Node: "laptop", Replacements: ReplacementsOf(defined, modules), Machines: []Machine{ {Name: "anchor", Domains: []string{"anchor" + InternalSuffix, "anchor.example"}, Addresses: []string{"192.0.2.10", "10.10.0.1"}}, {Name: "laptop", Domains: []string{"laptop" + InternalSuffix}, Addresses: []string{"10.10.0.4"}}, }} @@ -80,14 +80,14 @@ func TestSSHToAMeshMachineIsRefusedNamingTheTool(t *testing.T) { "ssh -o StrictHostKeyChecking=no 10.10.0.1 docker ps -a": "anchor/docker.docker_list", "ssh anchor docker logs --tail 100 nats": "anchor/docker.docker_logs", "ssh laptop docker logs x": "anchor/docker.docker_logs", - "ssh a uptime": "mesh-placement.placement", + "ssh a uptime": "mesh-module-assigner.assignments", "cd /tmp && ssh anchor journalctl": "anchor/node-service-manager.journal", "sudo -u root ssh anchor journalctl": "anchor/node-service-manager.journal", "timeout 10 ssh anchor journalctl": "anchor/node-service-manager.journal", "env LANG=C ssh anchor journalctl": "anchor/node-service-manager.journal", "bash -c 'ssh anchor journalctl -u x'": "anchor/node-service-manager.journal", `echo "$(ssh anchor journalctl -n 5)" | tail`: "anchor/node-service-manager.journal", - "for n in anchor laptop; do ssh $n uptime; done; ssh anchor uptime": "mesh-placement.placement", + "for n in anchor laptop; do ssh $n uptime; done; ssh anchor uptime": "mesh-module-assigner.assignments", "ssh -J anchor 203.0.113.9 journalctl": "anchor/node-service-manager.journal", "ssh via-anchor journalctl": "anchor/node-service-manager.journal", "ssh nas.lan journalctl": "anchor/node-service-manager.journal", diff --git a/modules/claude-code/cmd/claude-code/handacts.go b/modules/claude-code/cmd/claude-code/handacts.go new file mode 100644 index 000000000..6ec7e849f --- /dev/null +++ b/modules/claude-code/cmd/claude-code/handacts.go @@ -0,0 +1,16 @@ +package main + +import ( + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/views" +) + +// handActs records the hand acts of the views reader through the seat mesh-hand-acts's client. An act the seat does not +// take is an error, and the reader then does nothing (the views package's rule). +type handActs struct{} + +func (handActs) RecordHandAct(a views.HandAct) error { + _, err := meshhandactsclient.Recorder{Ask: stdio.Ask}.Record(meshhandactsclient.Request{What: a.What, Why: a.Why, Cause: a.Cause, Verb: a.Verb}) + return err +} diff --git a/modules/claude-code/cmd/claude-code/insteadof_test.go b/modules/claude-code/cmd/claude-code/insteadof_test.go index 0be167b24..9ac5a4d37 100644 --- a/modules/claude-code/cmd/claude-code/insteadof_test.go +++ b/modules/claude-code/cmd/claude-code/insteadof_test.go @@ -1,16 +1,25 @@ package main import ( + "context" "crypto/sha256" "encoding/json" "errors" "fmt" + assignerclient "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + mesh "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "os" + "reflect" + "slices" "strings" "testing" + "time" - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/seatdef" + "git.novox.be/novox/mesh-sdk/go/seatlist" + "git.novox.be/novox/mesh-sdk/go/views" ) // aBus is the states claude-code reads, as a test holds them: by read, key and value. @@ -33,33 +42,33 @@ func (b aBus) Get(read, key string) ([]byte, bool, error) { } // someSeats are two seats as the seats' contract says them: one held once for the mesh, one on every machine. -func someSeats() []catalogue.Seat { - return []catalogue.Seat{ - {Name: "mesh-placement", Scope: catalogue.ScopeMesh, Serves: []catalogue.Verb{ - {Name: "placement", Description: "Read placement: one node's key.", Replaces: []string{"hostnamectl", "uptime"}}, +func someSeats() []seatdef.Definition { + return []seatdef.Definition{ + {Seat: "mesh-module-assigner", Domain: "Core", Scope: "mesh", Verbs: []seatdef.Verb{ + {Name: "assignments", Description: "Read assignments: one node's key.", Replaces: []string{"hostnamectl", "uptime"}}, {Name: "propose", Description: "Propose a change."}}}, - {Name: "node-service-manager", Scope: catalogue.ScopeNode, Serves: []catalogue.Verb{ + {Seat: "node-service-manager", Domain: "Core", Scope: "node", Verbs: []seatdef.Verb{ {Name: "status", Description: "One unit as the service manager sees it now: its states, whether it starts at boot.", Replaces: []string{"systemctl status", "systemctl is-active"}}, {Name: "journal", Description: "The last lines of one unit's journal.", Replaces: []string{"journalctl"}}}}, } } // aMesh is two machines, anchor (with a public domain) and laptop, docker and zsh placed on anchor, docker's manifest -// saying what its tool replaces, as the seat mesh-placement and the indexer keep them on the bus. +// saying what its tool replaces, as the seat mesh-module-assigner and the indexer keep them on the bus. func aMesh(t *testing.T) aBus { t.Helper() - b := aBus{PlacementRead: {}, ModulesRead: {}, ManifestsRead: {}} - for _, p := range []delivery.Placement{ - {V: 1, Node: "anchor", Decided: delivery.NodeDecided{PublicDomain: "Anchor.Example"}, - Modules: []delivery.PlacedModule{{Module: "docker"}, {Module: "zsh"}}}, + b := aBus{AssignmentsRead: {}, ModulesRead: {}, ManifestsRead: {}} + for _, p := range []mesh.Assignments{ + {V: 1, Node: "anchor", Decided: mesh.NodeDecided{PublicDomain: "Anchor.Example"}, + Modules: []mesh.PlacedModule{{Module: "docker"}, {Module: "zsh"}}}, {V: 1, Node: "laptop"}, } { - values, err := delivery.PlacementValues(p) + values, err := assignerclient.AssignmentsValues(p) if err != nil { t.Fatal(err) } for k, v := range values { - b[PlacementRead][k] = v + b[AssignmentsRead][k] = v } } for module, manifest := range map[string]string{ @@ -67,14 +76,14 @@ func aMesh(t *testing.T) aBus { "zsh": `{"module":"zsh"}`, } { digest := fmt.Sprintf("%x", sha256.Sum256([]byte(manifest))) - mkey, _ := delivery.ManifestsKey(digest) - raw, err := json.Marshal(delivery.Manifest{V: 1, Module: module, JSON: manifest}) + mkey, _ := catalogclient.ManifestsKey(digest) + raw, err := json.Marshal(catalog.ManifestsByDigestValue{V: 1, Module: module, Manifest: manifest}) if err != nil { t.Fatal(err) } b[ManifestsRead][mkey] = raw - key, _ := delivery.ModulesKey(module) - b[ModulesRead][key], _ = json.Marshal(delivery.Module{V: 1, None: "nothing is built of it", Manifest: digest, Repository: "novox/mesh-catalog", Commit: "c"}) + key, _ := catalogclient.ModulesKey(module) + b[ModulesRead][key], _ = json.Marshal(catalog.ModulesByModuleValue{V: 1, None: "nothing is built of it", Manifest: digest, Repository: "novox/mesh-catalog", Commit: "c"}) } return b } @@ -96,9 +105,43 @@ func TestTheMeshsToolsAreReadFromTheBus(t *testing.T) { for _, r := range m.Replacements { addresses = append(addresses, r.Address) } - if got := strings.Join(addresses, ","); got != "mesh-placement.placement,node-service-manager.status,node-service-manager.journal,docker.docker_logs" { + if got := strings.Join(addresses, ","); got != "mesh-module-assigner.assignments,node-service-manager.status,node-service-manager.journal,docker.docker_logs" { t.Errorf("replacements: %s", got) } + t.Run("the again tool answers the operator and the healers only", func(t *testing.T) { + for caller, refused := range map[string]bool{"person.jochen": false, "shanks.mesh-healers": false, "shanks.mesh-config": true} { + if err := seatlist.Refusal("claude_code_seat_list_again", seatListAgainCallers, caller); (err != nil) != refused { + t.Errorf("%s: refused=%v, err %v", caller, refused, err) + } + } + }) + t.Run("the seats are the seat list read from the bus", func(t *testing.T) { + // A table made from what the seat mesh-catalog answers (ADR 0635 §1): no compiled list, so before the reader + // has started nothing replaces a command, and after it the seats' verbs are the seat's own. + seatListMu.Lock() + seatList = nil + seatListMu.Unlock() + if got := definedSeats(); len(got) != 0 { + t.Fatalf("seats before the reader started: %v", got) + } + startSeatList(listBus{"mesh-module-assigner": someSeats()[0], "node-service-manager": someSeats()[1]}) + defer func() { seatListMu.Lock(); seatList = nil; seatListMu.Unlock() }() + // The reader's first read ends when Ready closes (no sleeping). + select { + case <-seatList.Ready(): + case <-time.After(10 * time.Second): + t.Fatalf("the seat list was not read: %+v", seatList.Status()) + } + got, err := ReadMeshToolsFrom(aMesh(t), definedSeats(), resolving) + var addrs []string + for _, r := range got.Replacements { + addrs = append(addrs, r.Address) + } + // The foundation's bootstrapped seats are held beside the two the seat answered, in reading order. + if err != nil || !slices.Contains(addrs, "mesh-module-assigner.assignments") || !slices.Contains(addrs, "node-service-manager.journal") { + t.Fatalf("replacements from the bus: %v, %v", addrs, err) + } + }) if len(m.Machines) != 2 || strings.Join(m.Machines[0].Domains, ",") != "anchor.internal,anchor.example" || strings.Join(m.Machines[0].Addresses, ",") != "10.10.0.1,192.0.2.10" || strings.Join(m.Machines[1].Addresses, ",") != "10.10.0.4" { t.Errorf("machines: %+v", m.Machines) @@ -116,7 +159,7 @@ func TestTheInsteadOfTableIsGeneratedFromTheRecords(t *testing.T) { for _, want := range []string{ "| `/node-service-manager.` | `status` — `systemctl status`, `systemctl is-active` · `journal` — `journalctl` |", "| `/docker.` | `docker_logs` — `docker logs` |", - "| `mesh-placement.` | `placement` — `hostnamectl`, `uptime` |", + "| `mesh-module-assigner.` | `assignments` — `hostnamectl`, `uptime` |", "Never `ssh` to a mesh machine", } { if !strings.Contains(table, want) { @@ -124,14 +167,14 @@ func TestTheInsteadOfTableIsGeneratedFromTheRecords(t *testing.T) { } } if strings.Index(table, "node-service-manager.") > strings.Index(table, "docker.") || - strings.Index(table, "docker.") > strings.Index(table, "`mesh-placement.`") { + strings.Index(table, "docker.") > strings.Index(table, "`mesh-module-assigner.`") { t.Errorf("not the machines' seats, then modules, then the mesh's:\n%s", table) } // What the guard here named most comes first: its row, and in its row its verb. - m.Refused = map[string]int{"node-service-manager.journal": 3, "mesh-placement.placement": 1} + m.Refused = map[string]int{"node-service-manager.journal": 3, "mesh-module-assigner.assignments": 1} ranked := InsteadOf(&m) if !strings.Contains(ranked, "| `/node-service-manager.` | `journal` — `journalctl` · `status`") || - strings.Index(ranked, "`mesh-placement.`") > strings.Index(ranked, "`/docker.`") { + strings.Index(ranked, "`mesh-module-assigner.`") > strings.Index(ranked, "`/docker.`") { t.Errorf("not ranked by the guard's record:\n%s", ranked) } m.Refused = nil @@ -178,8 +221,8 @@ func TestTheMeshsToolsAreKeptOnlyWhenTheyChange(t *testing.T) { } } -// Where a module runs is read from the seat mesh-placement's placement (issue 1126 B13), never the controller's answer. -func TestTheMachinesRunningAModuleAreReadFromPlacement(t *testing.T) { +// Where a module runs is read from the seat mesh-module-assigner's assignments (issue 1126 B13), never the controller's answer. +func TestTheMachinesRunningAModuleAreReadFromAssignments(t *testing.T) { b := aMesh(t) if got, err := NodesRunning(b, "zsh"); err != nil || strings.Join(got, ",") != "anchor" { t.Errorf("zsh runs on %v (%v)", got, err) @@ -188,14 +231,48 @@ func TestTheMachinesRunningAModuleAreReadFromPlacement(t *testing.T) { t.Errorf("a module that runs nowhere: %v (%v)", got, err) } if _, err := NodesRunning(aBus{}, "zsh"); err == nil { - t.Error("an unreadable placement was read as no machine") + t.Error("an unreadable assignments was read as no machine") + } +} + +// ADR 0615 §1: the tools and the assignments come from the owners' first views and their views events, never their state. +// A reader whose first views are answered from the fixture reads what the state read gave, and a views event changes +// what it reads (CAT-CC3). +func TestTheMeshIsReadFromTheOwnersFirstViews(t *testing.T) { + bus := aMesh(t) + r := newMeshViews() + for _, name := range []string{ModulesRead, ManifestsRead, AssignmentsRead} { + state := strings.TrimPrefix(name, strings.Split(name, ".")[0]+".") + values := map[string]json.RawMessage{} + for k, v := range bus[name] { + values[k] = v + } + f := r.Follow(strings.Split(name, ".")[0], state, "x") + f.Ask = func(context.Context, string) (views.Answer, error) { + return views.Answer{V: views.Version, Owner: f.Owner, State: state, Revision: 5, Created: time.Unix(1, 0), Values: values, At: time.Now()}, nil + } + if err := f.Start(context.Background()); err != nil { + t.Fatal(err) + } + } + want, err := ReadMeshToolsFrom(bus, someSeats(), resolving) + if err != nil { + t.Fatal(err) + } + got, err := ReadMeshToolsFrom(viewStates{r}, someSeats(), resolving) + if err != nil || !reflect.DeepEqual(got, want) || len(got.Replacements) == 0 { + t.Fatalf("from the views: %+v, %v\nfrom the state: %+v", got, err, want) + } + // A reader not yet answered says so, and reads nothing. + if _, err := ReadMeshToolsFrom(viewStates{newMeshViews()}, someSeats(), resolving); err == nil || !strings.Contains(err.Error(), "no first view") { + t.Fatalf("an unanswered first view: %v", err) } } // Issue 1126 B13 AC1 (ADR 0590): over the seats the mesh defines, the rendered instructions, their table among them, // name no address of the controller, which is not placed: each job is named by the seat that owns it now. func TestTheRenderedInstructionsNameNoControllerAddress(t *testing.T) { - m, err := ReadMeshToolsFrom(aMesh(t), catalogue.Seats(), resolving) + m, err := ReadMeshToolsFrom(aMesh(t), someSeats(), resolving) if err != nil { t.Fatal(err) } @@ -207,3 +284,22 @@ func TestTheRenderedInstructionsNameNoControllerAddress(t *testing.T) { t.Errorf("the instructions name the controller:\n%s", md) } } + +// listBus answers the seat mesh-catalog's seat list from the definitions it is given. +type listBus map[string]seatdef.Definition + +func (b listBus) Ask(key string, body any) (json.RawMessage, error) { + if q, _ := body.(map[string]any); q != nil && q["seat"] != nil { + d := b[q["seat"].(string)] + d.Format, d.Grain, d.Decision = seatdef.Format, seatdef.SeatList, "ADR 0635" + raw, _ := json.Marshal(d) + return json.Marshal(seatlist.Answer{Seat: d.Seat, Found: true, Entry: &seatlist.Entry{Seat: d.Seat, Source: "trunk", Digest: "d-" + d.Seat, Definition: raw}}) + } + var l seatlist.Listing + for n := range b { + l.Seats = append(l.Seats, seatlist.Entry{Seat: n, Source: "trunk", Digest: "d-" + n}) + } + return json.Marshal(l) +} + +func (listBus) Subscribe(string, func(string, json.RawMessage) error) error { return nil } diff --git a/modules/claude-code/cmd/claude-code/instructions.go b/modules/claude-code/cmd/claude-code/instructions.go index 0fc2eb1a4..005086802 100644 --- a/modules/claude-code/cmd/claude-code/instructions.go +++ b/modules/claude-code/cmd/claude-code/instructions.go @@ -9,5 +9,5 @@ func instructionsText(node, role string) string { node + "`\n- **Role:** " + role + - "\n- The other nodes, their roles and what runs where: ask the placement seat (`mesh-placement.placement`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `.` (the mesh's own seats\n include `mesh-placement`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `/.`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n" + "\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `.` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `/.`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n" } diff --git a/modules/claude-code/cmd/claude-code/judge.go b/modules/claude-code/cmd/claude-code/judge.go index 724cfbd05..6491c44f0 100644 --- a/modules/claude-code/cmd/claude-code/judge.go +++ b/modules/claude-code/cmd/claude-code/judge.go @@ -71,6 +71,17 @@ type Judged struct { Pending bool `json:"pending,omitempty"` } +// JudgedEvent is the body of `judged`: one per judgement asked with a bound past JudgeBound, said when it ends, with +// its answer or why there is none. The question and the schema are never in it. +type JudgedEvent struct { + Ticket string `json:"ticket"` + Node string `json:"node"` + Model string `json:"model,omitempty"` + Seconds float64 `json:"seconds,omitempty"` + Answer json.RawMessage `json:"answer,omitempty"` + Error string `json:"error,omitempty"` +} + // running is one judgement asked with a bound past JudgeBound, until it is collected. type running struct { done chan struct{} @@ -92,6 +103,9 @@ type Judge struct { // Wait is how long a call waits for a long judgement before it answers its ticket; JudgeWait unless a test // says less. Wait time.Duration + // Say tells the bus a ticketed judgement ended: the event `judged`, once, under the message id it is given so a + // repeat is dropped. Nil says nothing (a test's judge); the ticket is collected by claude_code_judged all the same. + Say func(body JudgedEvent, id string) error turn chan struct{} mu sync.Mutex @@ -208,10 +222,28 @@ func (j *Judge) start(schema, model, question string, bound time.Duration) (*Jud go func() { r.out, r.err = j.run(schema, model, question, bound) close(r.done) + j.said(ticket, r) }() return j.Collect(ticket) } +// said tells the bus that the judgement under a ticket ended (the event `judged`), once. A failure to say is logged: the +// ticket stays collectible by claude_code_judged, and the asker's own bound ends its wait. +func (j *Judge) said(ticket string, r *running) { + if j.Say == nil { + return + } + e := JudgedEvent{Ticket: ticket, Node: j.P.Node} + if r.err != nil { + e.Error = r.err.Error() + } else { + e.Model, e.Seconds, e.Answer = r.out.Model, r.out.Seconds, r.out.Answer + } + if err := j.Say(e, "judged-"+j.P.Node+"-"+ticket); err != nil { + say("the judgement under ticket %s ended and could not be said on the bus: %v", ticket, err) + } +} + // Collect waits as long as a call may for the judgement under a ticket: its answer, why it failed, or that it is // still pending. A ticket is answered once; an answer nobody collects is kept JudgedKept past its bound. func (j *Judge) Collect(ticket string) (*Judged, error) { diff --git a/modules/claude-code/cmd/claude-code/judge_test.go b/modules/claude-code/cmd/claude-code/judge_test.go index 4fd3da1fe..8f48e003b 100644 --- a/modules/claude-code/cmd/claude-code/judge_test.go +++ b/modules/claude-code/cmd/claude-code/judge_test.go @@ -182,6 +182,9 @@ func TestALongJudgementIsCollectedByItsTicket(t *testing.T) { } }) j.Wait = 50 * time.Millisecond + said := make(chan JudgedEvent, 4) + ids := make(chan string, 4) + j.Say = func(e JudgedEvent, id string) error { said <- e; ids <- id; return nil } start := time.Now() out, err := j.Judge(JudgeAsk{Question: "a long record", Schema: verdictSchema, Within: 90 * time.Second}) if err != nil { @@ -203,6 +206,21 @@ func TestALongJudgementIsCollectedByItsTicket(t *testing.T) { if got.Pending || string(got.Answer) != `{"verdict":"none"}` || got.Ticket != out.Ticket { t.Fatalf("%+v", got) } + // One `judged` per ticket, with the answer, under an id of the ticket's own; none for a judgement with no ticket. + select { + case e := <-said: + if e.Ticket != out.Ticket || e.Node != "control" || string(e.Answer) != `{"verdict":"none"}` || e.Error != "" || e.Model != "sonnet" { + t.Fatalf("judged: %+v", e) + } + if id := <-ids; id != "judged-control-"+out.Ticket { + t.Fatalf("judged under %q", id) + } + case <-time.After(5 * time.Second): + t.Fatal("the ended judgement was not said") + } + if len(said) != 0 { + t.Fatal("judged said twice") + } // Read once the judgement is collected: its end is what orders the agent's write before this read. if left := deadline.Sub(start); left < 80*time.Second || left > 91*time.Second { t.Fatalf("the judgement's bound is %s, not the 90s asked", left) @@ -224,10 +242,23 @@ func TestALongJudgementIsBoundedAndSaysWhyItFailed(t *testing.T) { } j.Wait = 20 * time.Millisecond j.Bound = time.Second // the test's stand-in for JudgeBound: within is past it, so a ticket; far past Wait on a loaded machine + failed := make(chan JudgedEvent, 1) + j.Say = func(e JudgedEvent, _ string) error { failed <- e; return nil } out, err := j.Judge(JudgeAsk{Question: "q", Schema: verdictSchema, Within: 2 * time.Second}) if err != nil || !out.Pending { t.Fatalf("%+v %v", out, err) } + defer func() { + // A failed judgement is said too, with why and no answer. + select { + case e := <-failed: + if e.Ticket != out.Ticket || e.Answer != nil || !strings.Contains(e.Error, "did not answer within") { + t.Errorf("judged: %+v", e) + } + case <-time.After(5 * time.Second): + t.Error("the failed judgement was not said") + } + }() for i := 0; i < 3000; i++ { got, err := j.Collect(out.Ticket) if err != nil { diff --git a/modules/claude-code/cmd/claude-code/main.go b/modules/claude-code/cmd/claude-code/main.go index 3c53201ba..af9c534ff 100644 --- a/modules/claude-code/cmd/claude-code/main.go +++ b/modules/claude-code/cmd/claude-code/main.go @@ -12,6 +12,7 @@ package main import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -21,9 +22,12 @@ import ( "strings" "time" + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/asks" - "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatlist" + "git.novox.be/novox/mesh-sdk/go/views" ) func say(format string, args ...any) { @@ -180,12 +184,12 @@ func (s memoryStateOf) Delete(key string) error { return s.s.Delete(key) } var memoryCarrier *MemoryCarrier // sayCondition says a condition on the operator-channel seat under this module's name (novox/hq ADR 0437 §9). -func sayCondition(c asks.Condition) error { - subject, err := asks.ConditionSubject("claude-code") +func sayCondition(c oc.Condition) error { + subject, err := occ.ConditionSubject("claude-code") if err != nil { return err } - raw, err := asks.EncodeCondition(c) + raw, err := occ.EncodeCondition(c) if err != nil { return err } @@ -205,23 +209,53 @@ func (s stateOf) Get(key string) (json.RawMessage, bool, error) { return e.Value, true, nil } -// nodesRunningMe is the nodes claude-code runs on, from the seat mesh-placement's placement — for the register -// tool's question. -func nodesRunningMe() ([]string, error) { return NodesRunning(busStates{}, "claude-code") } +// nodesRunningMe is the nodes claude-code runs on, from the assigner's assignments — for the register tool's question. +func nodesRunningMe() ([]string, error) { return NodesRunning(viewStates{meshViews}, "claude-code") } -// busStates reads the states the manifest declares in its reads through the runtime. -type busStates struct{} +// The owners' read tools of the states this module follows, which the manifest invokes (novox/hq ADR 0615 §1). +// The holders whose views this module follows, as its manifest's `consumes` and `invokes` name them (the manifest test +// holds them equal). +const ( + assignerModule = "mesh-delivery-assigner" + indexerModule = "mesh-delivery-indexer" +) -func (busStates) Keys(read string) ([]string, error) { return stdio.State(read).Keys() } +const ( + ModulesTool = indexerModule + ".indexer_read_modules" + ManifestsTool = indexerModule + ".indexer_read_manifests" + AssignmentsTool = assignerModule + ".assigner_read_assignments" +) -func (busStates) Get(read, key string) ([]byte, bool, error) { - e, err := stdio.State(read).Get(key) - if err != nil || e == nil { - return nil, false, err - } - return e.Value, true, nil +// meshViews is this module's picture of the indexer's modules and manifests and the assigner's assignments: each asked +// once of the owner's read tool, then followed on the owner's views family. It reads none of their state. +var meshViews *views.Reader + +// meshChanged is told, without waiting, that a followed state changed: the instructions' table is read again. +var meshChanged = make(chan struct{}, 1) + +// newMeshViews builds the reader of those three states; Start asks them. +func newMeshViews() *views.Reader { + v := &views.Reader{Module: Module, Logf: func(f string, a ...any) { say(f, a...) }, Findings: meshconditionsclient.FindingWriter{State: stdio.State("findings")}, + Acts: handActs{}, + Changed: func(string, string, json.RawMessage, bool) { + select { + case meshChanged <- struct{}{}: + default: + } + }} + v.Follow(indexerModule, "modules", ModulesTool) + v.Follow(indexerModule, "manifests", ManifestsTool) + v.Follow(assignerModule, "assignments", AssignmentsTool) + return v } +// viewStates reads the followed states from the reader's pictures, never from the owners' state. +type viewStates struct{ v *views.Reader } + +func (s viewStates) Keys(read string) ([]string, error) { return s.v.Keys(read) } + +func (s viewStates) Get(read, key string) ([]byte, bool, error) { return s.v.Get(read, key) } + func fingerprintOfFile(path string) any { raw, err := os.ReadFile(path) if err != nil { @@ -291,7 +325,7 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c Description: "Write Claude Code's managed directory now, from the mesh's facts, this module's settings and the servers registered here — after reading the bus again for what each tool replaces and which machines the mesh has (the instructions' table and the shell's guard).", Run: func(map[string]any) (any, error) { answer := map[string]any{} - if m, err := ReadMeshToolsFrom(busStates{}, catalogue.Seats(), lookup); err != nil { + if m, err := ReadMeshToolsFrom(viewStates{meshViews}, definedSeats(), lookup); err != nil { answer["asked"] = "the bus could not be read in full (" + err.Error() + "); what was kept is rendered" } else { KeepMeshTools(p, m) @@ -372,7 +406,13 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c out = append(out, proposalTools(p)...) out = append(out, waitTools(p)...) judge := NewJudge(p) + // The event `judged`, one per ticket when its judgement ends: the asker hears it instead of asking again. + judge.Say = func(e JudgedEvent, id string) error { + return stdio.Emit("judged", e, stdio.EmitOptions{MessageID: id}) + } out = append(out, judgeTool(judge), judgedTool(judge)) + out = append(out, meshViews.Tools("claude_code")...) + out = append(out, seatListTools()...) return append(out, configTools(p, config, configView)...) } @@ -418,6 +458,7 @@ func main() { return } p, launched := PathsFrom(os.Getenv) + meshViews = newMeshViews() if launched { fsys = FilesFor(p) } @@ -447,6 +488,7 @@ func main() { } } } + meshViews.Start(context.Background()) go refreshMeshTools(p) servers := stateOf{stdio.State("servers")} view := NewServerView(p) @@ -624,3 +666,27 @@ func refusals(n int) string { } return fmt.Sprintf(" (after %d refusal(s))", n) } + +// seatListAgainCallers may read the seat list again: every operator's session and the healers (ADR 0636 §7). +var seatListAgainCallers = []string{stdio.AnyPerson, "mesh-healers"} + +// seatListTools are the seat list reader's two tools: what the list holds and what it waits for, and a read now. The +// reader starts once the runtime serves, after the tools are listed, so each tool finds it when it is called. +func seatListTools() []stdio.Tool { + names := []string{"claude_code_seat_list", "claude_code_seat_list_again"} + callers := seatListAgainCallers + shape := seatlist.Tools(&seatlist.Reader{}, names[0], names[1], callers) + for i := range shape { + run := i + shape[i].Run = func(args map[string]any) (any, error) { + seatListMu.Lock() + r := seatList + seatListMu.Unlock() + if r == nil { + return nil, errors.New("the seat list reader has not started yet: the runtime has not served this module") + } + return seatlist.Tools(r, names[0], names[1], callers)[run].Run(args) + } + } + return shape +} diff --git a/modules/claude-code/cmd/claude-code/mesh.go b/modules/claude-code/cmd/claude-code/mesh.go index d27b234b4..d543178d6 100644 --- a/modules/claude-code/cmd/claude-code/mesh.go +++ b/modules/claude-code/cmd/claude-code/mesh.go @@ -5,9 +5,9 @@ package main // replaces, and the mesh's machines by every name and address a session could reach one by. // // Read from the bus at start and every few minutes (issue 1126 B13; the controller that answered this before is -// not placed, novox/hq ADR 0590): the seats' verbs from the seats' contract (mesh-sdk catalogue), the modules' +// not placed, novox/hq ADR 0590): the seats' verbs from the seats' contract (mesh-contracts), the modules' // own tools from the manifests the indexer keeps (`mesh-delivery-indexer.modules` and `.manifests`), and the -// machines and where each module runs from the seat mesh-placement's `placement`. Kept in the module's state so +// machines and where each module runs from the seat mesh-module-assigner's `assignments`. Kept in the module's state so // a render never waits for the bus, and rendered whenever it changes. Nothing here is written by hand: a verb // that gains a `replaces` is in the next render's table and the guard's next refusal. @@ -16,15 +16,21 @@ import ( "encoding/json" "errors" "fmt" + assignerclient "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + mesh "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "net" "os" "path/filepath" "sort" "strings" + "sync" "time" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/seatdef" + "git.novox.be/novox/mesh-sdk/go/seatlist" ) // Replacement is one verb or tool and the shell commands it is the mesh's way to do. @@ -86,9 +92,9 @@ func ReadMeshTools(p Paths) *MeshTools { // The states read, each declared in the manifest's reads. const ( - PlacementRead = delivery.PlacementRead - ModulesRead = delivery.IndexerModule + ".modules" - ManifestsRead = delivery.IndexerModule + ".manifests" + AssignmentsRead = (assignerModule + "." + mesh.StateAssignments) + ModulesRead = indexerModule + "." + catalog.StateModules + ManifestsRead = indexerModule + "." + catalog.StateManifests ) // States reads the module states the manifest declares in its reads: a state's keys, and one key's value. @@ -104,17 +110,17 @@ type ModuleTools struct { Replaces map[string][]string } -// placements is every node's placement, from the seat mesh-placement's state. -func placements(st States) (map[string]delivery.Placement, error) { - keys, err := st.Keys(PlacementRead) +// assignments is every node's assignments, from the seat mesh-module-assigner's state. +func assignments(st States) (map[string]mesh.Assignments, error) { + keys, err := st.Keys(AssignmentsRead) if err != nil { - return nil, fmt.Errorf("%s: %w", PlacementRead, err) + return nil, fmt.Errorf("%s: %w", AssignmentsRead, err) } - read := delivery.NewPlacementKeys() + read := assignerclient.NewKeys() for _, k := range keys { - v, found, err := st.Get(PlacementRead, k) + v, found, err := st.Get(AssignmentsRead, k) if err != nil { - return nil, fmt.Errorf("%s %s: %w", PlacementRead, k, err) + return nil, fmt.Errorf("%s %s: %w", AssignmentsRead, k, err) } if found { _, _ = read.Put(k, v, 0, false) // a key that does not read places nothing @@ -123,8 +129,8 @@ func placements(st States) (map[string]delivery.Placement, error) { return read.All(), nil } -// runsOn is, by module, the nodes placement puts it on, sorted. -func runsOn(placed map[string]delivery.Placement) map[string][]string { +// runsOn is, by module, the nodes assignments puts it on, sorted. +func runsOn(placed map[string]mesh.Assignments) map[string][]string { out := map[string][]string{} for node, p := range placed { for _, m := range p.Modules { @@ -137,9 +143,9 @@ func runsOn(placed map[string]delivery.Placement) map[string][]string { return out } -// NodesRunning is the nodes placement puts module on. +// NodesRunning is the nodes assignments puts module on. func NodesRunning(st States, module string) ([]string, error) { - placed, err := placements(st) + placed, err := assignments(st) if err != nil { return nil, err } @@ -157,7 +163,7 @@ func modulesTools(st States, on map[string][]string) ([]ModuleTools, error) { } sort.Strings(modules) for _, module := range modules { - key, err := delivery.ModulesKey(module) + key, err := catalogclient.ModulesKey(module) if err != nil { continue } @@ -168,11 +174,11 @@ func modulesTools(st States, on map[string][]string) ([]ModuleTools, error) { } continue } - m, err := delivery.DecodeModule(raw) + m, err := catalogclient.DecodeModule(raw) if err != nil || m.Manifest == "" { continue } - mkey, err := delivery.ManifestsKey(m.Manifest) + mkey, err := catalogclient.ManifestsKey(m.Manifest) if err != nil { continue } @@ -183,7 +189,7 @@ func modulesTools(st States, on map[string][]string) ([]ModuleTools, error) { } continue } - kept, err := delivery.DecodeManifest(raw) + kept, err := catalogclient.DecodeManifest(raw) if err != nil { problems = append(problems, fmt.Errorf("the manifest of %s: %w", module, err)) continue @@ -191,7 +197,7 @@ func modulesTools(st States, on map[string][]string) ([]ModuleTools, error) { var man struct { Replaces map[string][]string `json:"replaces"` } - if json.Unmarshal([]byte(kept.JSON), &man) != nil || len(man.Replaces) == 0 { + if json.Unmarshal([]byte(kept.Manifest), &man) != nil || len(man.Replaces) == 0 { continue } out = append(out, ModuleTools{Module: module, On: on[module], Replaces: man.Replaces}) @@ -217,14 +223,14 @@ func firstSentence(s string) string { // ReplacementsOf is every seat verb and module tool that says it replaces a command: the seats' verbs first, in the // seats' order, then the modules' own tools by module and tool. -func ReplacementsOf(seats []catalogue.Seat, modules []ModuleTools) []Replacement { +func ReplacementsOf(defined []seatdef.Definition, modules []ModuleTools) []Replacement { var out []Replacement - for _, s := range seats { - for _, v := range s.Serves { + for _, s := range defined { + for _, v := range s.Verbs { if len(v.Replaces) == 0 { continue } - out = append(out, Replacement{Address: s.Name + "." + v.Name, NodeScoped: s.Scope == catalogue.ScopeNode, Seat: true, + out = append(out, Replacement{Address: s.Seat + "." + v.Name, NodeScoped: s.Scope == "node", Seat: true, Does: firstSentence(v.Description), Replaces: v.Replaces}) } } @@ -255,17 +261,17 @@ func lookup(name string) []string { return addrs } -// ReadMeshToolsFrom reads the bus now: the seats' verbs, the placed modules' own tools, and every node placement names +// ReadMeshToolsFrom reads the bus now: the seats' verbs, the placed modules' own tools, and every node assignments names // with its domains and addresses. A part that cannot be read is left out and said in the error; what was read is kept. -func ReadMeshToolsFrom(st States, seats []catalogue.Seat, resolve func(string) []string) (MeshTools, error) { +func ReadMeshToolsFrom(st States, defined []seatdef.Definition, resolve func(string) []string) (MeshTools, error) { var m MeshTools - placed, err := placements(st) + placed, err := assignments(st) if err != nil { - m.Replacements = ReplacementsOf(seats, nil) + m.Replacements = ReplacementsOf(defined, nil) return m, err } modules, merr := modulesTools(st, runsOn(placed)) - m.Replacements = ReplacementsOf(seats, modules) + m.Replacements = ReplacementsOf(defined, modules) for node, p := range placed { machine := Machine{Name: node, Domains: []string{node + InternalSuffix}} if d := strings.ToLower(strings.TrimSpace(p.Decided.PublicDomain)); d != "" { @@ -334,13 +340,24 @@ func guardFiles(p Paths, m *MeshTools) *GuardFiles { return &GuardFiles{Exe: exe, Data: GuardDataOf(p, m), WaitsDir: WaitsDirPath(p)} } -// MeshToolsEvery is how often the bus is read again. +// MeshToolsEvery is how often the machines' addresses are resolved again; the tools and the assignments are read again +// when their followed states change (a views event), not on this timer. const MeshToolsEvery = 10 * time.Minute -// refreshMeshTools reads the bus at start and every few minutes, and renders when what it said changed. +// MeshToolsFirst is how long the first read waits for the first views to be answered. +const MeshToolsFirst = 2 * time.Minute + +// refreshMeshTools reads the pictures once the first views are answered, and again whenever one changes (and the +// machines' addresses every few minutes), and renders when what it said changed. func refreshMeshTools(p Paths) { + select { + case <-meshViews.Ready(): + case <-time.After(MeshToolsFirst): + say("the first views of the tools and the assignments were not answered in %s; reading what is held", MeshToolsFirst) + } + startSeatList(seatlist.StdioBus()) for { - m, err := ReadMeshToolsFrom(busStates{}, catalogue.Seats(), lookup) + m, err := ReadMeshToolsFrom(viewStates{meshViews}, definedSeats(), lookup) if err != nil { say("reading what each tool replaces and which machines the mesh has: %v", err) } @@ -351,7 +368,15 @@ func refreshMeshTools(p Paths) { say("the mesh's tools changed: %d replace a command, %d machines; rendered", len(m.Replacements), len(m.Machines)) } } - time.Sleep(MeshToolsEvery) + select { + case <-meshChanged: + time.Sleep(2 * time.Second) // a burst of events is one read + select { + case <-meshChanged: + default: + } + case <-time.After(MeshToolsEvery): + } } } @@ -385,3 +410,46 @@ func GuardStatus(p Paths, last int) map[string]any { "record": ReadGuardLog(d.Log, last), } } + +// seatList is the seat list this module holds: the definitions of the seats, read from the seat mesh-catalog and +// followed on its events (novox/hq ADR 0635 §1, ADR 0636), the foundation's bootstrapped ones until it answers. +var seatList *seatlist.Reader + +// startSeatList starts the reader once the runtime serves; a change of the list is told like any followed state, so the +// instructions' table is read again. +func startSeatList(bus seatlist.Bus) { + r, err := seatlist.Start(seatlist.Config{Bus: bus, Bootstrap: seatlist.Bootstrapped(), + OnChange: func([]catalogue.Seat) { + select { + case meshChanged <- struct{}{}: + default: + } + }}) + if err != nil { + say("following the seat list: %v", err) + } + seatListMu.Lock() + seatList = r + seatListMu.Unlock() +} + +var seatListMu sync.Mutex + +// definedSeats are the held definitions in reading order, mesh seats first, then node seats, each by name; empty until +// the reader has started. +func definedSeats() []seatdef.Definition { + seatListMu.Lock() + r := seatList + seatListMu.Unlock() + if r == nil { + return nil + } + held := r.Definitions() + var out []seatdef.Definition + for _, s := range r.Seats() { + if d, ok := held[s.Name]; ok { + out = append(out, d) + } + } + return out +} diff --git a/modules/claude-code/cmd/claude-code/proposal_tools.go b/modules/claude-code/cmd/claude-code/proposal_tools.go index 84af45589..1887455c5 100644 --- a/modules/claude-code/cmd/claude-code/proposal_tools.go +++ b/modules/claude-code/cmd/claude-code/proposal_tools.go @@ -9,8 +9,8 @@ import ( "sync" "time" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/asks" ) // Proposing is what the proposal tools reach: the `proposals` state, the operator channel, the controller's @@ -71,9 +71,13 @@ func (d *Proposing) watch(p Paths) { } } go persist("hearing the operator's answers", func() error { - return stdio.Subscribe(asks.Seat+".decided."+Module, func(e stdio.Envelope) error { - var w asks.Warrant - if json.Unmarshal(e.Body, &w) == nil { + return stdio.Subscribe(oc.Seat+".decided."+Module, func(e stdio.Envelope) error { + var w oc.Warrant + if err := json.Unmarshal(e.Body, &w); err == nil { + if _, err := warrantAt(w); err != nil { + say("not taking the router's word on %s: %v", w.Ask, err) + return nil + } if strings.HasPrefix(w.Ask, QuestionPrefix) { HeardQuestionEnd(d.questions(), w) return nil @@ -112,14 +116,19 @@ func proposalTools(p Paths) []stdio.Tool { "(Telegram), shown the text (whole when it is short, else its first lines and its fingerprint), and the text " + "reaches those sessions only on Approve. Decline, or no answer within a day, discards it. Refused for text " + "that would run or read in something by itself (a shell line, front matter, an @ file reference), anything " + - "shaped like a secret, more than 16 KiB, a name set at the controller's terminal, or when no such channel is " + - "there (novox/hq ADR 0274, issue 340). claude_code_proposals shows where it stands.", + "shaped like a secret, more than 16 KiB, or when no such channel is there (novox/hq ADR 0274, issue 340). A name " + + "registered before for the mesh or the node, or set at the controller's terminal, is proposed too: as a " + + "replacement (the text) or with remove a removal of that registered section. The operator's ask says plainly " + + "that it replaces or removes the registered section, and shows the text the same way; on Approve every node " + + "renders the new text in the place of the old, on Decline or at expiry nothing changes. A replacement of a " + + "registered section may keep that section's own heading form (it replaces a mesh section); the heading rule " + + "stays for every new shared section. claude_code_proposals and claude_code_registrations show where it stands.", Input: map[string]any{ "scope": str("mesh (every node) or node (the nodes given, or this one)"), "nodes": str("for scope node: a comma-separated list of nodes; absent is this node"), "name": str("the section's name: lower-case letters, digits and -, e.g. handoff-from-g14"), "text": str("the section's text, markdown, as every session will read it"), - "remove": map[string]any{"type": "boolean", "description": "propose removing the approved section of this name and scope instead"}, + "remove": map[string]any{"type": "boolean", "description": "propose removing the approved or registered section of this name and scope instead"}, }, Run: func(a map[string]any) (any, error) { if proposing == nil { diff --git a/modules/claude-code/cmd/claude-code/proposals.go b/modules/claude-code/cmd/claude-code/proposals.go index 897c0a771..e3a32a129 100644 --- a/modules/claude-code/cmd/claude-code/proposals.go +++ b/modules/claude-code/cmd/claude-code/proposals.go @@ -15,7 +15,7 @@ package main // and lets this module read only its own asks); // 2. composes the ask again from the proposal — the words, the options, the expiry, the digest of the text — // and holds the router's warrant to it: the same ask digest, an option it offered, at its level, in time -// (asks.Warrant.For), and the option's bound act is this proposal's (asks.Option.Performs). An ask published +// (oc.Warrant.For), and the option's bound act is this proposal's (oc.Option.Performs). An ask published // under this module's name with other words, or a proposal changed after it was asked, finds the digests // differ and nothing is done; // 3. applies the approved text to its own file, `approved.json` in the module's state directory, which is what @@ -42,8 +42,9 @@ import ( "unicode" "unicode/utf8" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/asks" ) // Module is this module's name: the one the bus grants its asks under. @@ -68,7 +69,7 @@ const RouterKeeps = 30 * 24 * time.Hour // ProposalLasts is how long the operator has to answer: a day less a margin, so the router never refuses it for // lasting a day and a moment (the SDK's bound for an approval). -const ProposalLasts = asks.ApproveLasts - 10*time.Minute +const ProposalLasts = occ.ApproveLasts - 10*time.Minute // What a proposal does. const ( @@ -112,6 +113,10 @@ type Proposal struct { Form int `json:"form,omitempty"` // Asked is empty once the ask was published, or says why it was not; nothing composes from it. Asked string `json:"asked,omitempty"` + // Over is the digest of the text of a section registered before (for the mesh or the node, or set at the + // controller's terminal) that this proposal replaces or removes (novox/hq issue 340): the ask says so, and an + // approval is bound to it. Empty for a proposal that touches no registered section. + Over string `json:"over,omitempty"` } // textDigest is the digest a proposal binds: SHA-256 over the text's bytes. @@ -164,10 +169,15 @@ func joinAnd(list []string) string { // Act is what an answer binds: this proposal, whole, and the answer. The router's warrant names the ask's digest, // and the ask names this act's digest in the option, so an approval authorises this text and nothing else. -func (p Proposal) Act(option string) asks.Act { - return asks.Act{"verb": Module + ".instructions", "option": option, "proposal": p.ID, "action": p.Action, +func (p Proposal) Act(option string) occ.Act { + act := occ.Act{"verb": Module + ".instructions", "option": option, "proposal": p.ID, "action": p.Action, "scope": p.Scope, "nodes": strings.Join(p.Nodes, ","), "name": p.Name, "digest": p.Digest, "from": p.From, "at": p.At.UTC().Format(time.RFC3339Nano)} + if p.Over != "" { + // Only when set, so the act of a proposal made before keeps the digest its warrant names. + act["over"] = p.Over + } + return act } // AskGlance is the form of an ask read at a glance (Proposal.Form, Question.Form; novox/hq issue 639). @@ -175,7 +185,7 @@ const AskGlance = 1 // Ask is the ask for a proposal, composed from it alone: every node composes the same one, and holds the // router's warrant to it — in the form it was asked in. -func (p Proposal) Ask() asks.Ask { +func (p Proposal) Ask() oc.Ask { if p.Form == AskGlance { return p.glanceAsk() } @@ -185,7 +195,7 @@ func (p Proposal) Ask() asks.Ask { // glanceAsk is the ask read at a glance on the phone (novox/hq issue 639): its headline, and as its explanation // the summary of what it does; the text, where it came from, its fingerprint and where to read all of it are its // Details, which the router notes after the answers, collapsed where the channel can. -func (p Proposal) glanceAsk() asks.Ask { +func (p Proposal) glanceAsk() oc.Ask { verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action] shown, whole := excerpt(p.Text) part := "" @@ -193,13 +203,23 @@ func (p Proposal) glanceAsk() asks.Ask { part = " Part shown" } headline := fmt.Sprintf("%s Claude instructions '%s'?%s", verb, p.Name, part) - if len([]rune(headline)) > asks.HeadlineLength { + if len([]rune(headline)) > occ.HeadlineLength { headline = verb + " Claude instructions?" + part } var d strings.Builder if p.Action == ActionRemove { - fmt.Fprintf(&d, "Proposed through the claude-code module on %s at %s UTC. The approved text it removes has the fingerprint %s.", - p.From, p.At.UTC().Format("15:04 on 2 Jan"), p.Fingerprint()) + removes, print := "The approved text it removes", p.Fingerprint() + if p.Over != "" { + // The registered text, never the replacement's: a removal of a replacement removes both. + removes, print = "The registered text it removes", fingerprintOf(p.Over) + } + fmt.Fprintf(&d, "Proposed through the claude-code module on %s at %s UTC. %s has the fingerprint %s.", + p.From, p.At.UTC().Format("15:04 on 2 Jan"), removes, print) + if p.Over != "" && p.Digest != p.Over { + // A replacement is removed with the registered text it stands over: both fingerprints, the replacement's + // being the text sessions read. + fmt.Fprintf(&d, " The replacement sessions read, which it also removes, has the fingerprint %s.", p.Fingerprint()) + } } else { if whole { d.WriteString("The text:\n" + shown + "\n") @@ -208,17 +228,30 @@ func (p Proposal) glanceAsk() asks.Ask { } fmt.Fprintf(&d, "Proposed through the claude-code module on %s at %s UTC: %d lines, %d bytes, fingerprint %s.", p.From, p.At.UTC().Format("15:04 on 2 Jan"), p.Lines(), len(p.Text), p.Fingerprint()) + if p.Over != "" { + fmt.Fprintf(&d, " It replaces the registered instructions of this name, whose text has the fingerprint %s.", fingerprintOf(p.Over)) + } } fmt.Fprintf(&d, "\nRead all of it at the controller's terminal: mesh-cli ask claude-code claude_code_proposals '{\"id\": \"%s\"}'.", p.ID) does := "the text becomes part of those sessions' instructions" if p.Action == ActionRemove { does = "the instructions are removed from those sessions" } - return p.withOptions(asks.Ask{ID: p.ID, Headline: headline, Explanation: p.summary(), Details: d.String()}, does) + return p.withOptions(oc.Ask{ID: p.ID, Headline: headline, Explanation: p.summary(), Details: d.String()}, does) } // summary is what the proposal does, at a glance: its proposer's words, or the module's. func (p Proposal) summary() string { + // A proposal over a registered section says so in the module's words, never the proposer's: the operator is + // told plainly which section of the mesh's own it replaces or removes. + if p.Over != "" { + where := strings.ToUpper(p.where()[:1]) + p.where()[1:] + if p.Action == ActionRemove { + return fmt.Sprintf("%s stops reading the instructions '%s' registered before, from its next start.", where, p.Name) + } + return fmt.Sprintf("%s reads these %d lines in place of the instructions '%s' registered before, from its next start.", + where, p.Lines(), p.Name) + } if s := strings.TrimSpace(p.Summary); s != "" { return s } @@ -244,8 +277,8 @@ func SummaryProblem(summary string) string { return "" case strings.Contains(s, "\n"): return "the summary is more than one line" - case len([]rune(s)) > asks.SummaryLength: - return fmt.Sprintf("the summary is %d characters, more than the %d a phone shows at a glance", len([]rune(s)), asks.SummaryLength) + case len([]rune(s)) > occ.SummaryLength: + return fmt.Sprintf("the summary is %d characters, more than the %d a phone shows at a glance", len([]rune(s)), occ.SummaryLength) case !shownLine(s): return "the summary names a path, an address, a host name or a long word, which a phone is not shown" } @@ -254,8 +287,8 @@ func SummaryProblem(summary string) string { // withOptions is an ask with what every form of it carries: who answers, its expiry, what it is about, and its two // options, each binding its act. -func (p Proposal) withOptions(a asks.Ask, does string) asks.Ask { - a.Who, a.Expires, a.OnExpiry = asks.Operator, p.At.Add(ProposalLasts), "the proposal is discarded; nothing changes" +func (p Proposal) withOptions(a oc.Ask, does string) oc.Ask { + a.Who, a.Expires, a.OnExpiry = occ.Operator, occ.FormatTime(p.At.Add(ProposalLasts)), "the proposal is discarded; nothing changes" a.About = "instructions." + p.Scope + "." + strings.Join(p.Nodes, "+") + "." + p.Name if len(p.Nodes) == 0 { a.About = "instructions." + p.Scope + "." + p.Name @@ -264,15 +297,15 @@ func (p Proposal) withOptions(a asks.Ask, does string) asks.Ask { {OptionApprove, "Approve", does}, {OptionDecline, "Decline", "nothing changes; the proposal is discarded"}, } { - binds, _ := asks.ActDigest(p.Act(o.id)) - a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds}) + binds, _ := occ.ActDigest(p.Act(o.id)) + a.Options = append(a.Options, oc.Option{ID: o.id, Label: o.label, Does: o.does, Level: occ.Approve, Binds: binds}) } return a } // listedAsk is the ask as it was composed before issue 639, the text in its explanation: composed only to hold the // warrant of a proposal made then. -func (p Proposal) listedAsk() asks.Ask { +func (p Proposal) listedAsk() oc.Ask { verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action] shown, whole := excerpt(p.Text) part := "" @@ -280,7 +313,7 @@ func (p Proposal) listedAsk() asks.Ask { part = " Part shown" } headline := fmt.Sprintf("%s Claude instructions '%s'?%s", verb, p.Name, part) - if len([]rune(headline)) > asks.HeadlineLength { + if len([]rune(headline)) > occ.HeadlineLength { headline = verb + " Claude instructions?" + part } var b strings.Builder @@ -311,8 +344,8 @@ func (p Proposal) listedAsk() asks.Ask { if p.Action == ActionRemove { does = "the instructions are removed from those sessions" } - a := asks.Ask{ID: p.ID, Headline: headline, Explanation: strings.TrimRight(b.String(), "\n"), Who: asks.Operator, - Expires: p.At.Add(ProposalLasts), OnExpiry: "the proposal is discarded; nothing changes", + a := oc.Ask{ID: p.ID, Headline: headline, Explanation: strings.TrimRight(b.String(), "\n"), Who: occ.Operator, + Expires: occ.FormatTime(p.At.Add(ProposalLasts)), OnExpiry: "the proposal is discarded; nothing changes", About: "instructions." + p.Scope + "." + strings.Join(p.Nodes, "+") + "." + p.Name} if len(p.Nodes) == 0 { a.About = "instructions." + p.Scope + "." + p.Name @@ -321,8 +354,8 @@ func (p Proposal) listedAsk() asks.Ask { {OptionApprove, "Approve", does}, {OptionDecline, "Decline", "nothing changes; the proposal is discarded"}, } { - binds, _ := asks.ActDigest(p.Act(o.id)) - a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds}) + binds, _ := occ.ActDigest(p.Act(o.id)) + a.Options = append(a.Options, oc.Option{ID: o.id, Label: o.label, Does: o.does, Level: occ.Approve, Binds: binds}) } return a } @@ -433,7 +466,7 @@ func invisible(text string) string { // write the record and ask for it under this module's name. func ProposalProblem(p Proposal) string { switch { - case !asks.UsableID(p.ID): + case !occ.UsableID(p.ID): return "its id is not one" case !itemName.MatchString(p.Name): return "its name is not one" @@ -460,7 +493,7 @@ func ProposalProblem(p Proposal) string { } switch p.Action { case ActionAdd, ActionReplace: - if problem := ProposedTextProblem(p.Text); problem != "" { + if problem := proposedTextProblem(p.Text, p.Over != ""); problem != "" { return problem } if textDigest(p.Text) != p.Digest { @@ -473,6 +506,9 @@ func ProposalProblem(p Proposal) string { default: return "its action is not add, replace or remove" } + if p.Over != "" && !strings.HasPrefix(p.Over, "sha256:") { + return "the digest of the registered text it replaces is not one" + } return "" } @@ -488,7 +524,12 @@ var secretShaped = []*regexp.Regexp{ // session would run or read in by itself (issue 340's refusals, kept): a shell line, front matter, a hook, a tool // server, a permission mode or pre-approved tools, or an `@` reference, which the instruction file reads in as a // file; or anything shaped like a secret. -func ProposedTextProblem(text string) string { +func ProposedTextProblem(text string) string { return proposedTextProblem(text, false) } + +// proposedTextProblem is ProposedTextProblem; ownHeadings says the text replaces a section registered before, which +// may keep that section's own heading form (a replacement of a mesh section is a mesh section): the heading rule +// stays for every new shared section. +func proposedTextProblem(text string, ownHeadings bool) string { switch { case strings.TrimSpace(text) == "": return "the text is empty" @@ -514,6 +555,9 @@ func ProposedTextProblem(text string) string { return fmt.Sprintf("the text holds %q: an @ after anything but a letter or a digit may be read in as a file, "+ "and a shared section refers to no file (novox/hq issue 340)", strings.TrimSpace(m)) } + if ownHeadings { + return secretProblem(text) + } if m := setextHeading.FindString(text); m != "" { return "the text underlines a line with = or -, which makes it a heading that would read as a section of the mesh's own" } @@ -524,6 +568,10 @@ func ProposedTextProblem(text string) string { return fmt.Sprintf("the text holds the heading %q, which would read as a section of the mesh's own; a shared "+ "section uses headings of level four or deeper", strings.TrimSpace(m)) } + return secretProblem(text) +} + +func secretProblem(text string) string { for _, re := range secretShaped { if re.MatchString(text) { return "the text holds something shaped like a secret, which a shared section never carries" @@ -545,6 +593,11 @@ type ApprovedSection struct { From string `json:"from"` Approved time.Time `json:"approved"` By string `json:"by"` + // Over is the digest of the registered section's text this one replaced or removed; the registered section of + // that name and text is left out of what renders. Removes marks a removal of a registered section: it holds no + // text, and its Digest is the fingerprint of the text removed. + Over string `json:"over,omitempty"` + Removes bool `json:"removes,omitempty"` } // ProposalDecision is what this node made of one proposal: once decided, never again. @@ -651,8 +704,11 @@ func ApprovedHere(a Approved, node string) []ApprovedSection { var out []ApprovedSection for _, s := range a.Sections { // Judged again as it renders: a record changed on disk renders nothing a proposal could not hold. - if !itemName.MatchString(s.Name) || !nodeName.MatchString(s.From) || ProposedTextProblem(s.Text) != "" || - textDigest(s.Text) != s.Digest || (s.Scope != ScopeMesh && s.Scope != ScopeNode) || + textOK := proposedTextProblem(s.Text, s.Over != "") == "" && textDigest(s.Text) == s.Digest + if s.Removes { + textOK = s.Text == "" && s.Over != "" && strings.HasPrefix(s.Digest, "sha256:") + } + if !itemName.MatchString(s.Name) || !nodeName.MatchString(s.From) || !textOK || (s.Scope != ScopeMesh && s.Scope != ScopeNode) || (s.Scope == ScopeNode && (len(s.Nodes) == 0 || nodesProblem(s.Nodes) != "")) { continue } @@ -809,50 +865,62 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time } prop := Proposal{ID: newProposalID(), Scope: in.Scope, Nodes: nodes, Name: in.Name, From: p.Node, At: now.UTC(), Summary: strings.TrimSpace(in.Summary), Form: AskGlance} + registered, err := registeredSection(p, in.Scope, nodes, in.Name) + if err != nil { + return refuse(err.Error()) + } switch { case in.Remove: - if !isActive { - return refuse(fmt.Sprintf("no approved section %q at the %s scope is held on this node to remove; one set at the "+ - "controller's terminal is removed there", in.Name, in.Scope)) + switch { + case isActive && active.Removes: + return refuse(fmt.Sprintf("the section %q was removed already", in.Name)) + case isActive: + // An approved section that replaced a registered one is removed with it: nothing of the name is left. + prop.Action, prop.Digest, prop.Over = ActionRemove, active.Digest, active.Over + case registered != "": + prop.Action, prop.Digest, prop.Over = ActionRemove, registered, registered + default: + return refuse(fmt.Sprintf("no approved or registered section %q at the %s scope is held on this node to remove", in.Name, in.Scope)) } - prop.Action, prop.Digest = ActionRemove, active.Digest default: - if problem := ProposedTextProblem(in.Text); problem != "" { + prop.Over = registered + if prop.Over == "" && isActive { + prop.Over = active.Over + } + if problem := proposedTextProblem(in.Text, prop.Over != ""); problem != "" { return refuse(problem) } prop.Action, prop.Text, prop.Digest = ActionAdd, in.Text, textDigest(in.Text) - if isActive { + if isActive && !active.Removes { if active.Digest == prop.Digest { return refuse(fmt.Sprintf("the section %q already holds exactly this text", in.Name)) } prop.Action = ActionReplace - } - } - if terminal, _ := terminalText(p); len(terminal) > 0 { - for _, it := range terminal { - if it.Kind == KindInstructions && it.Name == in.Name { - return refuse(fmt.Sprintf("a section %q is set at the controller's terminal; it is changed there", in.Name)) + } else if registered != "" { + if registered == prop.Digest { + return refuse(fmt.Sprintf("the registered section %q already holds exactly this text", in.Name)) } + prop.Action = ActionReplace } } if why := approverReady(bus); why != "" { return refuse(why + "; " + AtTheTerminalInstead) } - if waiting := waitingProposals(store, approved, now); waiting >= asks.MostOpen { + if waiting := waitingProposals(store, approved, now); waiting >= occ.MostOpen { return refuse(fmt.Sprintf("%d proposals already wait for the operator's answer, and the operator is asked at most %d "+ - "questions at once; claude_code_proposals lists them", waiting, asks.MostOpen)) + "questions at once; claude_code_proposals lists them", waiting, occ.MostOpen)) } if problem := ProposalProblem(prop); problem != "" { return refuse(problem) } ask := prop.Ask() - if err := ask.Check(now); err != nil { + if err := occ.Check(ask, now); err != nil { return refuse(err.Error()) } if err := store.Create(prop.ID, prop); err != nil { return nil, fmt.Errorf("the proposal could not be kept, so the operator was not asked: %w", err) } - if err := bus.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil { + if err := bus.Publish(oc.AcceptSubjectAsk(Module), ask, ask.ID); err != nil { prop.Asked = "not asked: " + err.Error() _ = store.Put(prop.ID, prop) say("proposal %s (%s %s %s) could not be asked: %v", prop.ID, prop.Action, prop.Scope, prop.Name, err) @@ -874,11 +942,15 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time } time.Sleep(RouterAnswersEvery) } + expires, err := askExpires(ask) + if err != nil { + return nil, err + } return map[string]any{ "router": taken, "proposed": prop.ID, "action": prop.Action, "reaches": prop.where(), "fingerprint": prop.Fingerprint(), "asked": "the operator is asked on a channel that proves who answers: \"" + ask.Headline + "\"", - "expires": ask.Expires.Format(time.RFC3339), + "expires": expires.Format(time.RFC3339), "until then": "nothing changes on any node; the text becomes part of those sessions only on the operator's Approve, " + "and is discarded on Decline or at expiry. claude_code_proposals shows where it stands", }, nil @@ -927,10 +999,10 @@ var ( // read. var ( endedMu sync.Mutex - ended = map[string]asks.Warrant{} + ended = map[string]oc.Warrant{} ) -func endedAsk(id string) (asks.Warrant, bool) { +func endedAsk(id string) (oc.Warrant, bool) { endedMu.Lock() defer endedMu.Unlock() w, ok := ended[id] @@ -940,8 +1012,8 @@ func endedAsk(id string) (asks.Warrant, bool) { // HeardEnd takes the router's word that one of this module's asks ended without a choice: refused, expired, // cancelled or replaced. The proposal is discarded here at once. Nothing is applied on such a word, so it needs no // proof beyond its subject; a choice is only ever taken from the router's record (Reconcile). -func HeardEnd(p Paths, store ProposalStore, w asks.Warrant, now time.Time) (string, error) { - if w.Asker != Module || w.Outcome == asks.OutcomeChosen || !asks.UsableID(w.Ask) { +func HeardEnd(p Paths, store ProposalStore, w oc.Warrant, now time.Time) (string, error) { + if w.Asker != Module || w.Outcome == occ.OutcomeChosen || !occ.UsableID(w.Ask) { return "", nil } reconcileMu.Lock() @@ -968,7 +1040,7 @@ func HeardEnd(p Paths, store ProposalStore, w asks.Warrant, now time.Time) (stri } // Heard, not read from a record: Reconcile still reads the router's record of it, so an ask the router took after // all (its queue offered it again once a channel was ready) is answered as any other. - d := ProposalDecision{At: now.UTC(), Says: w.Says(), Outcome: "discarded: the ask " + string(w.Outcome), Heard: true} + d := ProposalDecision{At: now.UTC(), Says: occ.Says(w), Outcome: "discarded: the ask " + string(w.Outcome), Heard: true} if w.Words != "" { d.Outcome += " (" + w.Words + ")" } @@ -986,9 +1058,9 @@ func HeardEnd(p Paths, store ProposalStore, w asks.Warrant, now time.Time) (stri // routerRecord is the router's record of one ask, as far as this module reads it. type routerRecord struct { - State string `json:"state"` - Ask *asks.Ask `json:"ask"` - Warrant *asks.Warrant `json:"warrant"` + State string `json:"state"` + Ask *oc.Ask `json:"ask"` + Warrant *oc.Warrant `json:"warrant"` } var reconcileMu sync.Mutex @@ -1010,7 +1082,7 @@ func Reconcile(p Paths, store ProposalStore, bus AskBus, record Recorder, now ti } type closed struct { prop Proposal - w asks.Warrant + w oc.Warrant } var ready []closed discarded := false @@ -1027,8 +1099,8 @@ func Reconcile(p Paths, store ProposalStore, bus AskBus, record Recorder, now ti if done { // Discarded on the router's word alone: only a record of a person's choice reopens it. var rec routerRecord - if err == nil && entry != nil && json.Unmarshal(entry.Value, &rec) == nil && rec.Warrant != nil && - rec.Warrant.Outcome == asks.OutcomeChosen { + if err == nil && entry != nil && json.Unmarshal(entry.Value, &rec) == nil && rec.check() == nil && rec.Warrant != nil && + rec.Warrant.Outcome == occ.OutcomeChosen { ready = append(ready, closed{prop, *rec.Warrant}) } continue @@ -1046,12 +1118,16 @@ func Reconcile(p Paths, store ProposalStore, bus AskBus, record Recorder, now ti continue // open, not yet recorded, or not readable now: asked again on the next look } var rec routerRecord - if json.Unmarshal(entry.Value, &rec) != nil || rec.State == "open" || rec.Warrant == nil { + if json.Unmarshal(entry.Value, &rec) != nil || rec.check() != nil || rec.State == "open" || rec.Warrant == nil { continue } ready = append(ready, closed{prop, *rec.Warrant}) } - sort.SliceStable(ready, func(i, j int) bool { return ready[i].w.At.Before(ready[j].w.At) }) + sort.SliceStable(ready, func(i, j int) bool { + a, _ := warrantAt(ready[i].w) // checked when read + b, _ := warrantAt(ready[j].w) + return a.Before(b) + }) changed, wrote := false, discarded var said []string for _, c := range ready { @@ -1108,17 +1184,21 @@ func Reconcile(p Paths, store ProposalStore, bus AskBus, record Recorder, now ti // decide holds one closed ask's warrant to its proposal, and applies it to this node's record when it holds. final // is false when the answer did not hold here: it is judged again on the next look, and nothing is applied. -func decide(p Paths, approved *Approved, prop Proposal, w asks.Warrant, now time.Time) (ProposalDecision, bool, string, bool) { - d := ProposalDecision{At: now.UTC(), Says: w.Says()} +func decide(p Paths, approved *Approved, prop Proposal, w oc.Warrant, now time.Time) (ProposalDecision, bool, string, bool) { + d := ProposalDecision{At: now.UTC(), Says: occ.Says(w)} refused := func(why string) (ProposalDecision, bool, string, bool) { d.Outcome = "refused: " + why say("REFUSED the answer to proposal %s: %s", prop.ID, why) return d, false, "proposal " + prop.ID + ": refused, " + why, false } + at, err := warrantAt(w) + if err != nil { + return refused(err.Error()) + } if w.Asker != Module || w.Ask != prop.ID { return refused(fmt.Sprintf("the router's record is for %s's ask %s", w.Asker, w.Ask)) } - if w.Outcome != asks.OutcomeChosen { + if w.Outcome != occ.OutcomeChosen { d.Outcome = "discarded: the ask " + string(w.Outcome) if w.Words != "" { d.Outcome += " (" + w.Words + ")" @@ -1130,11 +1210,11 @@ func decide(p Paths, approved *Approved, prop Proposal, w asks.Warrant, now time if problem := ProposalProblem(prop); problem != "" { return refused("the proposal is not one this module makes: " + problem) } - option, err := w.For(Module, prop.Ask()) + option, err := occ.For(w, Module, prop.Ask()) if err != nil { return refused(err.Error()) } - if err := option.Performs(prop.Act(option.ID)); err != nil { + if err := occ.Performs(option, prop.Act(option.ID)); err != nil { return refused(err.Error()) } d.Warranted = true @@ -1146,7 +1226,7 @@ func decide(p Paths, approved *Approved, prop Proposal, w asks.Warrant, now time return refused("the option " + option.ID + " is not one a proposal offers") } key := sectionKey(prop.Scope, prop.Nodes, prop.Name) - if last, ok := approved.Changed[key]; ok && !w.At.After(last) { + if last, ok := approved.Changed[key]; ok && !at.After(last) { d.Outcome = "approved, and superseded here: a change of this section approved later was applied first" return d, false, "proposal " + prop.ID + ": " + d.Outcome, true } @@ -1155,18 +1235,25 @@ func decide(p Paths, approved *Approved, prop Proposal, w asks.Warrant, now time switch prop.Action { case ActionRemove: held, ok := approved.Sections[key] - if !ok || held.Digest != prop.Digest { + switch { + case prop.Over != "" && (!ok || (!held.Removes && held.Digest == prop.Digest)): + // A registered section is removed by leaving it out of what renders: the record of the removal says + // which text, and a node holding no such section renders nothing of it either way. + approved.Sections[key] = ApprovedSection{Name: prop.Name, Scope: prop.Scope, Nodes: prop.Nodes, Digest: prop.Digest, + Proposal: prop.ID, From: prop.From, Approved: at.UTC(), By: occ.Says(w), Over: prop.Over, Removes: true} + case !ok || held.Removes || held.Digest != prop.Digest: d.Outcome = "approved, nothing to remove here: this node holds no section " + prop.Name + " with that fingerprint" - approved.Changed[key] = w.At.UTC() + approved.Changed[key] = at.UTC() return d, false, "proposal " + prop.ID + ": " + d.Outcome, true + default: + delete(approved.Sections, key) } - delete(approved.Sections, key) default: approved.Sections[key] = ApprovedSection{Name: prop.Name, Scope: prop.Scope, Nodes: prop.Nodes, Text: prop.Text, - Digest: prop.Digest, Proposal: prop.ID, From: prop.From, Approved: w.At.UTC(), By: w.Says()} + Digest: prop.Digest, Proposal: prop.ID, From: prop.From, Approved: at.UTC(), By: occ.Says(w), Over: prop.Over} } - approved.Changed[key] = w.At.UTC() - say("proposal %s (%s %s %s, fingerprint %s): %s", prop.ID, prop.Action, prop.Scope, prop.Name, prop.Fingerprint(), w.Says()) + approved.Changed[key] = at.UTC() + say("proposal %s (%s %s %s, fingerprint %s): %s", prop.ID, prop.Action, prop.Scope, prop.Name, prop.Fingerprint(), occ.Says(w)) return d, reaches, "proposal " + prop.ID + ": approved, " + prop.Action + " " + prop.Name, true } @@ -1198,6 +1285,13 @@ func ListProposals(p Paths, store ProposalStore, bus AskBus, id string, now time row := map[string]any{"id": prop.ID, "action": prop.Action, "name": prop.Name, "reaches": prop.where(), "from": prop.From, "at": prop.At.Format(time.RFC3339), "fingerprint": prop.Fingerprint(), "lines": prop.Lines(), "bytes": len(prop.Text)} + if prop.Over != "" { + verb := "replaces" + if prop.Action == ActionRemove { + verb = "removes" + } + row["registered section"] = verb + " the registered section whose text has the fingerprint " + fingerprintOf(prop.Over) + } if d, ok := approved.Decided[prop.ID]; ok { row["here"] = d } else if why, ok := approved.Refused[prop.ID]; ok { @@ -1222,12 +1316,12 @@ func ListProposals(p Paths, store ProposalStore, bus AskBus, id string, now time // the router took, both read now. if entry, err := bus.Record(prop.ID); err == nil && entry != nil { var rec routerRecord - if json.Unmarshal(entry.Value, &rec) == nil { + if json.Unmarshal(entry.Value, &rec) == nil && rec.check() == nil { row["router"] = rec.State switch { case rec.Ask == nil: row["the phone"] = "cannot be compared: the router's record keeps no ask" - case rec.Ask.Digest() == prop.Ask().Digest(): + case occ.Digest(*rec.Ask) == occ.Digest(prop.Ask()): row["the phone"] = "shows the ask composed from exactly this text (fingerprint " + prop.Fingerprint() + ")" default: row["the phone"] = "shows ANOTHER ask than this record composes: do not approve it" @@ -1239,11 +1333,58 @@ func ListProposals(p Paths, store ProposalStore, bus AskBus, id string, now time } var here []map[string]any for _, s := range ApprovedHere(approved, p.Node) { - here = append(here, map[string]any{"name": s.Name, "scope": s.Scope, "nodes": s.Nodes, "proposal": s.Proposal, + row := map[string]any{"name": s.Name, "scope": s.Scope, "nodes": s.Nodes, "proposal": s.Proposal, "from": s.From, "approved": s.Approved.Format(time.RFC3339), "by": s.By, - "fingerprint": fingerprintOf(s.Digest)}) + "fingerprint": fingerprintOf(s.Digest)} + if s.Over != "" { + row["registered section"] = "replaces the registered section whose text has the fingerprint " + fingerprintOf(s.Over) + if s.Removes { + row["registered section"] = "removes the registered section whose text has the fingerprint " + fingerprintOf(s.Over) + } + } + here = append(here, row) } return map[string]any{"proposals": rows, "approved here": here, "how": "a proposal becomes active on a node only when that node has held the operator's warrant, read from the " + "router's own record, to the proposal's exact text (novox/hq ADR 0274)"}, nil } + +// registeredSection is the digest of the text of the instruction section of this name that was registered before +// (for the mesh, or for this node when the proposal names it) or is set at the controller's terminal, or "" when +// there is none; an error when the places hold different texts under the name, since an approval is bound to one. +func registeredSection(p Paths, scope string, nodes []string, name string) (string, error) { + var items map[string]Item + _ = readJSON(p.config(), &items) + c := ConfigOf(Taken(items)) + c.Terminal, _ = terminalText(p) + type place struct { + items []Item + says string + } + places := []place{{c.Mesh, "registered for the mesh (unregister it with claude_code_instructions_unregister, scope mesh, name " + name + ")"}, + {c.Terminal, "set at the controller's terminal (its instructions setting)"}} + if scope == ScopeNode { + places = []place{{c.Node, "registered for this node (unregister it with claude_code_instructions_unregister, scope node, name " + name + ")"}, + {c.Terminal, "set at the controller's terminal (its instructions setting)"}} + if !contains(nodes, p.Node) { + places = places[1:] + } + } + digest := "" + var holding []string + for _, pl := range places { + for _, it := range pl.items { + if it.Kind != KindInstructions || it.Name != name { + continue + } + d := textDigest(it.Files[name+".md"]) + if digest != "" && d != digest { + return "", fmt.Errorf("the section %q is registered with different texts: %s and %s; an approval is bound to one text, "+ + "so unregister one of them first", name, strings.Join(holding, ", "), pl.says) + } + digest = d + holding = append(holding, pl.says) + } + } + return digest, nil +} diff --git a/modules/claude-code/cmd/claude-code/proposals_test.go b/modules/claude-code/cmd/claude-code/proposals_test.go index e1e199f16..7e2a3f806 100644 --- a/modules/claude-code/cmd/claude-code/proposals_test.go +++ b/modules/claude-code/cmd/claude-code/proposals_test.go @@ -13,8 +13,9 @@ import ( "testing" "time" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/asks" ) // memProposals is the `proposals` state, shared by every node of a test as the bus shares it. @@ -46,7 +47,7 @@ func (s *memProposals) Get(key string) (json.RawMessage, bool, error) { // router is the operator channel's holder as the module reaches it: what was asked, its record of each ask, and // the channel kinds. type router struct { - published map[string]asks.Ask + published map[string]oc.Ask subjects []string records map[string]json.RawMessage kinds []stdio.KindHeld @@ -54,7 +55,7 @@ type router struct { } func newRouter() *router { - return &router{published: map[string]asks.Ask{}, records: map[string]json.RawMessage{}, + return &router{published: map[string]oc.Ask{}, records: map[string]json.RawMessage{}, kinds: []stdio.KindHeld{{Seat: "channel", Kind: "telegram", Module: "telegram", Node: "novox", Capabilities: []string{"choice", "exact-render", "verified-sender"}}}} } @@ -65,7 +66,7 @@ func (r *router) Publish(subject string, body any, id string) error { } r.subjects = append(r.subjects, subject) raw, _ := json.Marshal(body) - var a asks.Ask + var a oc.Ask _ = json.Unmarshal(raw, &a) r.published[a.ID] = a r.records[a.ID], _ = json.Marshal(map[string]any{"state": "open"}) @@ -81,19 +82,19 @@ func (r *router) Record(id string) (*stdio.StateEntry, error) { func (r *router) Kinds() ([]stdio.KindHeld, error) { return r.kinds, nil } // close ends an ask with a warrant, as the router records it. -func (r *router) close(w asks.Warrant) { +func (r *router) close(w oc.Warrant) { w.Asker = Module state := string(w.Outcome) r.records[w.Ask], _ = json.Marshal(map[string]any{"state": state, "warrant": w}) } // chose is the warrant for a person's choice of an option of the ask as the router took it. -func (r *router) chose(id, option string, at time.Time) asks.Warrant { +func (r *router) chose(id, option string, at time.Time) oc.Warrant { a := r.published[id] - o, _ := a.Option(option) - return asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeChosen, Option: o.ID, Label: o.Label, Level: o.Level, - By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}, - Channel: "telegram", Proofs: []string{"P1"}, At: at, AskDigest: a.Digest()} + o, _ := occ.OptionOf(a, option) + return oc.Warrant{Ask: id, Asker: Module, Outcome: occ.OutcomeChosen, Option: o.ID, Label: o.Label, Level: o.Level, + By: &oc.Person{Who: occ.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}, + Channel: "telegram", Proofs: []string{"P1"}, At: occ.FormatTime(at), AskDigest: occ.Digest(a)} } type recorded struct{ asks []string } @@ -144,13 +145,13 @@ func TestAProposalAsksTheOperatorAtTheLevelApproveAndChangesNothing(t *testing.T t.Fatalf("the ask was published on %v, not under this module's own name", r.subjects) } a := r.published[id] - if err := a.Check(t0); err != nil { + if err := occ.Check(a, t0); err != nil { t.Fatalf("the router would refuse the ask: %v", err) } // At a glance (issue 639): what it does is the explanation, one line; the text is the Details, which the // router notes collapsed after the answers. if a.Explanation != "Every Claude session on the mesh reads these 4 lines as the mesh's own word, from its next start until they are removed the same way." || - asks.Glance(a.Explanation) != nil { + occ.Glance(a.Explanation) != nil { t.Errorf("the phone does not say what is asked at a glance: %q", a.Explanation) } if a.Headline != "Add Claude instructions 'handoff-from-g14'?" { @@ -163,11 +164,11 @@ func TestAProposalAsksTheOperatorAtTheLevelApproveAndChangesNothing(t *testing.T t.Fatalf("options: %+v", a.Options) } for _, o := range a.Options { - if o.Level != asks.Approve || !strings.HasPrefix(o.Binds, "sha256:") { + if o.Level != occ.Approve || !strings.HasPrefix(o.Binds, "sha256:") { t.Errorf("the option %s is %s binding %q: both answers need a proven sender and bind the act", o.ID, o.Level, o.Binds) } } - if !a.Expires.Equal(t0.Add(ProposalLasts)) { + if a.Expires != occ.FormatTime(t0.Add(ProposalLasts)) { t.Errorf("expires %s", a.Expires) } // Nothing is active, here or anywhere, while the ask is open. @@ -212,10 +213,10 @@ func TestNothingBecomesActiveWithoutAValidWarrant(t *testing.T) { }{ {"declined", func(r *router, _ *memProposals, id string) { r.close(r.chose(id, OptionDecline, t0.Add(time.Hour))) }, "declined"}, {"expired", func(r *router, _ *memProposals, id string) { - r.close(asks.Warrant{Ask: id, Outcome: asks.OutcomeExpired, At: t0.Add(24 * time.Hour)}) + r.close(oc.Warrant{Ask: id, Outcome: occ.OutcomeExpired, At: occ.FormatTime(t0.Add(24 * time.Hour))}) }, "discarded"}, {"refused by the router", func(r *router, _ *memProposals, id string) { - r.close(asks.Warrant{Ask: id, Outcome: asks.OutcomeRefused, Words: "no channel can carry it", At: t0}) + r.close(oc.Warrant{Ask: id, Outcome: occ.OutcomeRefused, Words: "no channel can carry it", At: occ.FormatTime(t0)}) }, "discarded"}, {"the text changed after the ask, with its digest", func(r *router, store *memProposals, id string) { var prop Proposal @@ -243,7 +244,7 @@ func TestNothingBecomesActiveWithoutAValidWarrant(t *testing.T) { }, "refused"}, {"answered at the level acknowledge", func(r *router, _ *memProposals, id string) { w := r.chose(id, OptionApprove, t0.Add(time.Hour)) - w.Level = asks.Acknowledge + w.Level = occ.Acknowledge r.close(w) }, "refused"}, {"no person chose", func(r *router, _ *memProposals, id string) { @@ -398,11 +399,10 @@ func TestWhatASessionWouldRunIsRefused(t *testing.T) { if _, err := Propose(p, ProposeInput{Scope: ScopeNode, Nodes: []string{"all"}, Name: "x", Text: "t\n"}, store, r, t0); err == nil { t.Error("all at the node scope was taken") } - // A name the terminal sets is changed there. + // A name the terminal sets is replaced by a proposal over it (TestARegisteredSectionIsReplacedOrRemovedOnTheOperatorsApproval). writeFile(t, p.Settings, `{"instructions": {"x": ["set at the terminal"]}}`) - if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "x", Text: "t\n"}, store, r, t0); err == nil || - !strings.Contains(err.Error(), "controller's terminal") { - t.Errorf("a terminal name was proposed: %v", err) + if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "x", Text: "t\n"}, store, r, t0); err != nil { + t.Errorf("a terminal name was not proposed over: %v", err) } } @@ -428,7 +428,7 @@ func TestALongTextIsShownInPartAndReadWholeAtTheTerminal(t *testing.T) { !strings.Contains(a.Details, "fingerprint "+out["fingerprint"].(string)) { t.Errorf("the phone does not say where to read all of it: %q", a.Details) } - if len(a.Details) > 3000 || asks.Glance(a.Explanation) != nil { + if len(a.Details) > 3000 || occ.Glance(a.Explanation) != nil { t.Errorf("the details are %d bytes; the explanation %q", len(a.Details), a.Explanation) } list, err := ListProposals(p, store, r, id, t0) @@ -496,7 +496,7 @@ func TestATextChangedPastWhatThePhoneShowsIsRefused(t *testing.T) { _ = json.Unmarshal(store.m[id], &prop) prop.Text = strings.Replace(prop.Text, "Ask before you push.", "Push without asking.", 1) _ = store.Put(id, prop) - if prop.Ask().Digest() != r.published[id].Digest() { + if occ.Digest(prop.Ask()) != occ.Digest(r.published[id]) { t.Fatal("the test means a change the ask's words do not show") } r.close(r.chose(id, OptionApprove, t0.Add(time.Hour))) @@ -559,7 +559,7 @@ func TestAForgedRecordIsNotAppliedEvenWhenApproved(t *testing.T) { } { prop.Digest = textDigest(prop.Text) _ = store.Put(prop.ID, prop) - if err := r.Publish(asks.AskSubject(Module), prop.Ask(), prop.ID); err != nil { + if err := r.Publish(oc.AcceptSubjectAsk(Module), prop.Ask(), prop.ID); err != nil { t.Fatal(err) } r.close(r.chose(prop.ID, OptionApprove, t0.Add(time.Hour))) @@ -718,8 +718,8 @@ func (r *refusingRouter) Publish(subject string, body any, id string) error { r.id = id go func() { time.Sleep(50 * time.Millisecond) - _, _ = HeardEnd(r.p, r.store, asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeRefused, - Words: "no channel can carry any of its answers now: telegram is not linked", At: t0}, t0) + _, _ = HeardEnd(r.p, r.store, oc.Warrant{Ask: id, Asker: Module, Outcome: occ.OutcomeRefused, + Words: "no channel can carry any of its answers now: telegram is not linked", At: occ.FormatTime(t0)}, t0) }() return nil } @@ -729,12 +729,23 @@ func TestAnAskTakenAfterItsRefusalIsStillAnswered(t *testing.T) { p, _ := node(t, "novox") store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter() id := proposeHandover(t, p, store, r) - if _, err := HeardEnd(p, store, asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeRefused, Words: "no channel", At: t0}, t0); err != nil { + if _, err := HeardEnd(p, store, oc.Warrant{Ask: id, Asker: Module, Outcome: occ.OutcomeRefused, Words: "no channel", At: occ.FormatTime(t0)}, t0); err != nil { t.Fatal(err) } if got := outcomeHere(t, p, id); !strings.HasPrefix(got, "discarded") { t.Fatalf("decided %q", got) } + // A warrant whose time does not parse is refused, naming the field, as the decode of it was. + bad := r.chose(id, OptionApprove, t0.Add(time.Hour)) + bad.At = "yesterday" + prop, _ := readProposal(store, id) + appr := Approved{Decided: map[string]ProposalDecision{}, Changed: map[string]time.Time{}, Refused: map[string]string{}} + if d, _, _, _ := decide(p, &appr, prop, bad, t0.Add(time.Hour)); !strings.HasPrefix(d.Outcome, "refused: the warrant's at") { + t.Errorf("a warrant with a malformed at was decided %q", d.Outcome) + } + if (routerRecord{Warrant: &bad}).check() == nil { + t.Error("a router record with a malformed at passed its check") + } r.close(r.chose(id, OptionApprove, t0.Add(time.Hour))) if changed, _, _ := Reconcile(p, store, r, nil, t0.Add(time.Hour)); !changed { t.Fatal("the approval of an ask taken after its refusal was lost") @@ -769,7 +780,7 @@ func TestAProposalIsAskedAtAGlanceAndAnOlderOneAsItWasAsked(t *testing.T) { older := prop older.Form = 0 listed := older.Ask() - if !strings.Contains(listed.Explanation, "The whole text:") || listed.Details != "" || listed.Digest() == prop.Ask().Digest() { + if !strings.Contains(listed.Explanation, "The whole text:") || listed.Details != "" || occ.Digest(listed) == occ.Digest(prop.Ask()) { t.Errorf("a proposal made before is not composed as it was asked: %q", listed.Explanation) } } diff --git a/modules/claude-code/cmd/claude-code/questions.go b/modules/claude-code/cmd/claude-code/questions.go index 7247d3527..07b5d1231 100644 --- a/modules/claude-code/cmd/claude-code/questions.go +++ b/modules/claude-code/cmd/claude-code/questions.go @@ -11,7 +11,7 @@ package main // // The session reads the answer by the question's key with claude_code_answer: from the router's own record of the // ask, which only the router writes and only this module reads, held to the question as the `questions` state keeps -// it (asks.Warrant.For and asks.Option.Performs). A record that does not hold is said as not proven, never as a +// it (oc.Warrant.For and oc.Option.Performs). A record that does not hold is said as not proven, never as a // choice. An answer informs the session; it authorises nothing (ADR 0286 stays the way to an approved call). import ( @@ -27,8 +27,9 @@ import ( "sync" "time" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/asks" ) // QuestionsState is the module's state the questions are kept in. @@ -38,16 +39,16 @@ const QuestionsState = "questions" const QuestionPrefix = "q-" // MostQuestions is how many questions may wait at once, mesh-wide: the router allows an asker three open asks -// (asks.MostOpen), and questions alone never fill them, so one is left for a proposed instruction section (ADR 0274) +// (occ.MostOpen), and questions alone never fill them, so one is left for a proposed instruction section (ADR 0274) // while two questions are open. Each open question holds one slot key in the `questions` state (slotKey), taken by // create or by compare-and-set, so two nodes asking at once cannot both take the last one. -const MostQuestions = asks.MostOpen - 1 +const MostQuestions = occ.MostOpen - 1 // A question the router holds no record of, and whose refusal was not heard here, keeps its slot until it expires: // the router may still have it in its queue (down, or backlogged), and neither the queue's order nor a cancel can // take it back — the queue offers a piece of work again after an error, so a cancel may be taken before the ask it // cancels, and a cancel of an ask not yet recorded is dropped. Once the question has expired the router refuses it -// when it is taken (asks.Ask.Check), so the slot is free and the question has ended. +// when it is taken (oc.Ask.Check), so the slot is free and the question has ended. // slotKey is the key of one of the MostQuestions slots: not a question's key, so no question is ever read from it. func slotKey(n int) string { return fmt.Sprintf("slot-%d", n) } @@ -69,7 +70,7 @@ const ( // The bounds of what a question says. const ( FewestQuestionOptions = 2 - MostContextBytes = asks.SummaryLength + MostContextBytes = occ.SummaryLength MostMeansBytes = 200 MostWhyBytes = 120 MostOnExpiryBytes = 200 @@ -151,8 +152,8 @@ func (q Question) recommended() (QuestionOption, bool) { } // Act is what an answer binds: this question, whole, and the option chosen. -func (q Question) Act(option string) asks.Act { - act := asks.Act{"verb": Module + ".question", "option": option, "question": q.ID, "headline": q.Headline, +func (q Question) Act(option string) occ.Act { + act := occ.Act{"verb": Module + ".question", "option": option, "question": q.ID, "headline": q.Headline, "context": q.Context, "why": q.Why, "on-expiry": q.OnExpiry, "by": q.By, "from": q.From, "at": q.At.UTC().Format(time.RFC3339Nano), "expires": q.Expires.UTC().Format(time.RFC3339Nano), "options": strconv.Itoa(len(q.Options))} @@ -163,7 +164,7 @@ func (q Question) Act(option string) asks.Act { } // Ask is the ask for a question, composed from it alone, in the form it was asked in. -func (q Question) Ask() asks.Ask { +func (q Question) Ask() oc.Ask { if q.Form == AskGlance { return q.glanceAsk() } @@ -172,31 +173,31 @@ func (q Question) Ask() asks.Ask { // glanceAsk is the question read at a glance on the phone (novox/hq issue 639): its context, then the recommended // answer and why as its one point; who asked and what the answer does are its Details. -func (q Question) glanceAsk() asks.Ask { +func (q Question) glanceAsk() oc.Ask { explanation := strings.TrimSpace(q.Context) if rec, ok := q.recommended(); ok { explanation += fmt.Sprintf("\n• Recommended: %s, because %s.", rec.Label, strings.TrimRight(strings.TrimSpace(q.Why), ".")) } details := fmt.Sprintf("Asked by the agent session %s on %s through the claude-code module, at %s UTC. Your answer is told "+ "to that session; it performs nothing by itself.", q.By, q.From, q.At.UTC().Format("15:04 on 2 Jan")) - return q.withOptions(asks.Ask{ID: q.ID, Headline: q.Headline, Explanation: explanation, Details: details}) + return q.withOptions(oc.Ask{ID: q.ID, Headline: q.Headline, Explanation: explanation, Details: details}) } -func (q Question) withOptions(a asks.Ask) asks.Ask { - a.Who, a.Expires, a.OnExpiry = asks.Operator, q.Expires, q.OnExpiry +func (q Question) withOptions(a oc.Ask) oc.Ask { + a.Who, a.Expires, a.OnExpiry = occ.Operator, occ.FormatTime(q.Expires), q.OnExpiry for _, o := range q.Options { does := o.Means if o.Recommended { does = "Recommended. " + does } - binds, _ := asks.ActDigest(q.Act(o.ID)) - a.Options = append(a.Options, asks.Option{ID: o.ID, Label: o.Label, Does: does, Level: asks.Approve, Binds: binds}) + binds, _ := occ.ActDigest(q.Act(o.ID)) + a.Options = append(a.Options, oc.Option{ID: o.ID, Label: o.Label, Does: does, Level: occ.Approve, Binds: binds}) } return a } // listedAsk is the ask as it was composed before issue 639: composed only to hold the answer to a question asked then. -func (q Question) listedAsk() asks.Ask { +func (q Question) listedAsk() oc.Ask { var b strings.Builder b.WriteString(strings.TrimSpace(q.Context)) if rec, ok := q.recommended(); ok { @@ -205,15 +206,15 @@ func (q Question) listedAsk() asks.Ask { } fmt.Fprintf(&b, "\n\nAsked by the agent session %s on %s through the claude-code module, at %s UTC. "+ "Your answer is told to that session; it performs nothing by itself.", q.By, q.From, q.At.UTC().Format("15:04 on 2 Jan")) - a := asks.Ask{ID: q.ID, Headline: q.Headline, Explanation: b.String(), Who: asks.Operator, Expires: q.Expires, + a := oc.Ask{ID: q.ID, Headline: q.Headline, Explanation: b.String(), Who: occ.Operator, Expires: occ.FormatTime(q.Expires), OnExpiry: q.OnExpiry} for _, o := range q.Options { does := o.Means if o.Recommended { does = "Recommended. " + does } - binds, _ := asks.ActDigest(q.Act(o.ID)) - a.Options = append(a.Options, asks.Option{ID: o.ID, Label: o.Label, Does: does, Level: asks.Approve, Binds: binds}) + binds, _ := occ.ActDigest(q.Act(o.ID)) + a.Options = append(a.Options, oc.Option{ID: o.ID, Label: o.Label, Does: does, Level: occ.Approve, Binds: binds}) } return a } @@ -228,8 +229,8 @@ func QuestionProblem(q Question) string { switch { case strings.TrimSpace(q.Headline) == "": add("it has no headline") - case len([]rune(q.Headline)) > asks.HeadlineLength: - add("its headline is %d characters, more than %d", len([]rune(q.Headline)), asks.HeadlineLength) + case len([]rune(q.Headline)) > occ.HeadlineLength: + add("its headline is %d characters, more than %d", len([]rune(q.Headline)), occ.HeadlineLength) } switch { case strings.TrimSpace(q.Context) == "": @@ -239,16 +240,16 @@ func QuestionProblem(q Question) string { case strings.Contains(strings.TrimSpace(q.Context), "\n"): add("its context is more than one line: a phone shows it at a glance as one short paragraph") } - if n := len(q.Options); n < FewestQuestionOptions || n > asks.MostOptions { - add("it offers %d options; a question offers %d to %d", n, FewestQuestionOptions, asks.MostOptions) + if n := len(q.Options); n < FewestQuestionOptions || n > occ.MostOptions { + add("it offers %d options; a question offers %d to %d", n, FewestQuestionOptions, occ.MostOptions) } recommended, labels := 0, map[string]bool{} for i, o := range q.Options { switch { case strings.TrimSpace(o.Label) == "": add("option %d has no label", i+1) - case len([]rune(o.Label)) > asks.LabelLength: - add("option %d's label is longer than %d characters", i+1, asks.LabelLength) + case len([]rune(o.Label)) > occ.LabelLength: + add("option %d's label is longer than %d characters", i+1, occ.LabelLength) case labels[strings.ToLower(o.Label)]: add("two options are labelled %q", o.Label) } @@ -258,7 +259,7 @@ func QuestionProblem(q Question) string { } else if len(o.Means) > MostMeansBytes { add("what option %d means is longer than %d bytes", i+1, MostMeansBytes) } - if !asks.UsableID(o.ID) { + if !occ.UsableID(o.ID) { add("option %d's id is not one", i+1) } if o.Recommended { @@ -368,7 +369,7 @@ func AskQuestion(p Paths, in QuestionInput, store QuestionStore, bus AskBus, now return refuse(problem) } ask := q.Ask() - if err := ask.Check(now); err != nil { + if err := occ.Check(ask, now); err != nil { return refuse(err.Error()) } // The question is kept before its slot is taken, so a slot always names a question another node can judge. @@ -384,7 +385,7 @@ func AskQuestion(p Paths, in QuestionInput, store QuestionStore, bus AskBus, now "at once, so one of the router's three open asks is left for a proposed instruction section; read them with "+ "claude_code_answer, or wait for one to end", len(open), strings.Join(open, ", "), MostQuestions)) } - if err := bus.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil { + if err := bus.Publish(oc.AcceptSubjectAsk(Module), ask, ask.ID); err != nil { _ = store.Put(q.ID, tombstone(q, "not asked: "+err.Error())) return nil, fmt.Errorf("the operator could not be asked: %v", err) } @@ -517,10 +518,10 @@ func readQuestionErr(store QuestionStore, key string) (Question, bool, error) { // The router's word that a question ended without a choice, as heard on the decided subject. var ( questionEndsMu sync.Mutex - questionEnds = map[string]asks.Warrant{} + questionEnds = map[string]oc.Warrant{} ) -func questionEnd(id string) (asks.Warrant, bool) { +func questionEnd(id string) (oc.Warrant, bool) { questionEndsMu.Lock() defer questionEndsMu.Unlock() w, ok := questionEnds[id] @@ -537,8 +538,8 @@ func forgetQuestionEnd(id string) { // replaced — and keeps it on the question, because a refused ask leaves no record of the router's. A refused question // keeps none of its words (tombstone). A choice is never taken from this word: only from the router's record, held to // the question (ReadAnswer). -func HeardQuestionEnd(store QuestionStore, w asks.Warrant) { - if w.Asker != Module || w.Outcome == asks.OutcomeChosen || !strings.HasPrefix(w.Ask, QuestionPrefix) { +func HeardQuestionEnd(store QuestionStore, w oc.Warrant) { + if w.Asker != Module || w.Outcome == occ.OutcomeChosen || !strings.HasPrefix(w.Ask, QuestionPrefix) { return } q, known := readQuestion(store, w.Ask) @@ -550,7 +551,7 @@ func HeardQuestionEnd(store QuestionStore, w asks.Warrant) { if w.Words != "" { ended += ": " + w.Words } - if w.Outcome == asks.OutcomeRefused { + if w.Outcome == occ.OutcomeRefused { q = tombstone(q, ended) } else { q.Ended = ended @@ -589,7 +590,10 @@ func ReadAnswer(store QuestionStore, bus AskBus, key string, now time.Time) (map } var rec routerRecord if entry != nil { - if err := json.Unmarshal(entry.Value, &rec); err != nil { + if err := json.Unmarshal(entry.Value, &rec); err != nil || rec.check() != nil { + if err == nil { + err = rec.check() + } out["state"], out["words"] = AnswerNotProven, "the router's record of it cannot be read: "+err.Error() return out, nil } @@ -613,12 +617,12 @@ func ReadAnswer(store QuestionStore, bus AskBus, key string, now time.Time) (map } default: w := *rec.Warrant - if w.Outcome != asks.OutcomeChosen { + if w.Outcome != occ.OutcomeChosen { out["state"], out["words"] = AnswerEnded, string(w.Outcome) if w.Words != "" { out["words"] = string(w.Outcome) + ": " + w.Words } - if w.Outcome == asks.OutcomeExpired { + if w.Outcome == occ.OutcomeExpired { out["now"] = "do what you said: " + q.OnExpiry } return out, nil @@ -627,9 +631,9 @@ func ReadAnswer(store QuestionStore, bus AskBus, key string, now time.Time) (map out["state"], out["words"] = AnswerNotProven, "the question kept is not one this module asks: "+problem return out, nil } - option, err := w.For(Module, q.Ask()) + option, err := occ.For(w, Module, q.Ask()) if err == nil { - err = option.Performs(q.Act(option.ID)) + err = occ.Performs(option, q.Act(option.ID)) } if err != nil { say("REFUSED the answer to question %s: %v", q.ID, err) @@ -642,8 +646,9 @@ func ReadAnswer(store QuestionStore, bus AskBus, key string, now time.Time) (map chosen["means"], chosen["recommended"] = o.Means, o.Recommended } } - out["state"], out["chosen"], out["by"], out["channel"], out["at"] = AnswerChosen, chosen, w.Says(), w.Channel, - w.At.Format(time.RFC3339) + at, _ := warrantAt(w) // checked with the record above + out["state"], out["chosen"], out["by"], out["channel"], out["at"] = AnswerChosen, chosen, occ.Says(w), w.Channel, + at.Format(time.RFC3339) out["note"] = "the operator's word to this session; it authorises no call that needs the operator's approval" } return out, nil @@ -663,3 +668,37 @@ func WaitForAnswer(store QuestionStore, bus AskBus, key string, wait time.Durati time.Sleep(AnswerLooksEvery) } } + +// warrantAt is when a warrant was made; the contract carries it as an RFC 3339 string, and one that does not parse is +// refused, as the decode of the router's word was before the contract carried it as a string. +func warrantAt(w oc.Warrant) (time.Time, error) { + t, err := occ.Time(w.At) + if err != nil { + return time.Time{}, fmt.Errorf("the warrant's at %q is not a time: %w", w.At, err) + } + return t, nil +} + +// askExpires is when an ask ends, read from the contract's RFC 3339 string; one that does not parse is refused. +func askExpires(a oc.Ask) (time.Time, error) { + t, err := occ.Time(a.Expires) + if err != nil { + return time.Time{}, fmt.Errorf("the ask's expires %q is not a time: %w", a.Expires, err) + } + return t, nil +} + +// check refuses a router record whose warrant or ask carries a time that does not parse. +func (r routerRecord) check() error { + if r.Warrant != nil { + if _, err := warrantAt(*r.Warrant); err != nil { + return err + } + } + if r.Ask != nil { + if _, err := askExpires(*r.Ask); err != nil { + return err + } + } + return nil +} diff --git a/modules/claude-code/cmd/claude-code/questions_test.go b/modules/claude-code/cmd/claude-code/questions_test.go index 64d7dc34e..8ff3fd74a 100644 --- a/modules/claude-code/cmd/claude-code/questions_test.go +++ b/modules/claude-code/cmd/claude-code/questions_test.go @@ -12,8 +12,9 @@ import ( "testing" "time" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/asks" ) func mergeTonight() QuestionInput { @@ -50,18 +51,18 @@ func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) { if !strings.HasPrefix(key, QuestionPrefix) || !strings.Contains(out["router"].(string), "took the question") { t.Fatalf("answered %v", out) } - if len(r.subjects) != 1 || r.subjects[0] != asks.AskSubject(Module) { + if len(r.subjects) != 1 || r.subjects[0] != oc.AcceptSubjectAsk(Module) { t.Fatalf("published on %v", r.subjects) } a := r.published[key] - if a.Who != asks.Operator || a.OnExpiry != "I leave the pull request open" || !a.Expires.Equal(t0.Add(QuestionDefault)) { + if a.Who != occ.Operator || a.OnExpiry != "I leave the pull request open" || a.Expires != occ.FormatTime(t0.Add(QuestionDefault)) { t.Errorf("ask %+v", a) } if len(a.Options) != 2 { t.Fatalf("options %+v", a.Options) } for _, o := range a.Options { - if o.Level != asks.Approve || o.Binds == "" { + if o.Level != occ.Approve || o.Binds == "" { t.Errorf("option %s is %s binding %q: every option is an approval bound to the question", o.ID, o.Level, o.Binds) } } @@ -69,7 +70,7 @@ func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) { t.Errorf("the recommendation is not on the recommended option alone: %+v", a.Options) } // At a glance (issue 639): the context and the recommendation are the explanation; who asks is the Details. - if !strings.HasSuffix(a.Explanation, "\n• Recommended: Merge now, because it is reviewed and the checks are green.") || asks.Glance(a.Explanation) != nil { + if !strings.HasSuffix(a.Explanation, "\n• Recommended: Merge now, because it is reviewed and the checks are green.") || occ.Glance(a.Explanation) != nil { t.Errorf("the explanation does not read at a glance:\n%s", a.Explanation) } for _, want := range []string{"the agent session fix-589 on g14", "performs nothing by itself"} { @@ -77,7 +78,7 @@ func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) { t.Errorf("the details do not say %q:\n%s", want, a.Details) } } - if err := a.Check(t0); err != nil { + if err := occ.Check(a, t0); err != nil { t.Errorf("the ask does not pass the asks contract: %v", err) } } @@ -212,8 +213,8 @@ func (r *questionRefusingRouter) Publish(subject string, body any, id string) er r.id = id go func() { time.Sleep(50 * time.Millisecond) - HeardQuestionEnd(r.store, asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeRefused, - Words: "its words carry path (a file path), which may not leave the mesh", At: t0}) + HeardQuestionEnd(r.store, oc.Warrant{Ask: id, Asker: Module, Outcome: occ.OutcomeRefused, + Words: "its words carry path (a file path), which may not leave the mesh", At: occ.FormatTime(t0)}) }() return nil } @@ -247,7 +248,7 @@ func TestAnExpiredQuestionSaysWhatTheSessionDoes(t *testing.T) { p, _ := node(t, "g14") store, r := newMemQuestions(), newRouter() key := askMergeTonight(t, p, store, r, t0) - r.close(asks.Warrant{Ask: key, Outcome: asks.OutcomeExpired, At: t0.Add(QuestionDefault)}) + r.close(oc.Warrant{Ask: key, Outcome: occ.OutcomeExpired, At: occ.FormatTime(t0.Add(QuestionDefault))}) out, err := ReadAnswer(store, r, key, t0.Add(QuestionDefault+time.Minute)) if err != nil || out["state"] != AnswerEnded || out["now"] != "do what you said: I leave the pull request open" { t.Errorf("expired: %v %v", out, err) @@ -276,7 +277,7 @@ func TestTheQuestionToolsAreServed(t *testing.T) { var asked map[string]any _ = json.Unmarshal([]byte(text), &asked) key, _ := asked["key"].(string) - if a := r.published[key]; !a.Expires.Equal(t0.Add(30 * time.Minute)) { + if a := r.published[key]; a.Expires != occ.FormatTime(t0.Add(30*time.Minute)) { t.Errorf("expires %v", a.Expires) } r.close(r.chose(key, "o1", t0.Add(time.Minute))) @@ -381,10 +382,10 @@ func TestTheEdgesOfAQuestionAreAsked(t *testing.T) { // A warrant naming another asker or another ask, or an option the question did not offer, is no answer to it. func TestAWarrantForAnotherAskOrOptionIsNotProven(t *testing.T) { p, _ := node(t, "g14") - for name, change := range map[string]func(*asks.Warrant){ - "another asker": func(w *asks.Warrant) { w.Asker = "mesh-controller" }, - "another ask": func(w *asks.Warrant) { w.Ask = "q-0000000000000000" }, - "an option unoffered": func(w *asks.Warrant) { w.Option, w.Label = "o9", "Delete everything" }, + for name, change := range map[string]func(*oc.Warrant){ + "another asker": func(w *oc.Warrant) { w.Asker = "mesh-controller" }, + "another ask": func(w *oc.Warrant) { w.Ask = "q-0000000000000000" }, + "an option unoffered": func(w *oc.Warrant) { w.Option, w.Label = "o9", "Delete everything" }, } { store, r := newMemQuestions(), newRouter() key := askMergeTonight(t, p, store, r, t0) @@ -474,7 +475,7 @@ func TestASlotIsTakenOnlyAtTheRevisionRead(t *testing.T) { store, r := newMemQuestions(), newRouter() // Two questions that ended: their slots are free to take again. for _, id := range []string{askMergeTonight(t, p, store, r, t0), askMergeTonight(t, p, store, r, t0)} { - r.close(asks.Warrant{Ask: id, Outcome: asks.OutcomeExpired, At: t0.Add(time.Minute)}) + r.close(oc.Warrant{Ask: id, Outcome: occ.OutcomeExpired, At: occ.FormatTime(t0.Add(time.Minute))}) } shanks, _ := node(t, "shanks") other := newRouter() @@ -487,7 +488,7 @@ func TestASlotIsTakenOnlyAtTheRevisionRead(t *testing.T) { From: shanks.Node, At: t0, Expires: t0.Add(time.Hour), Options: []QuestionOption{{ID: "o1", Label: "A", Means: "a", Recommended: true}, {ID: "o2", Label: "B", Means: "b"}}} _ = store.Put(q.ID, q) - _ = other.Publish(asks.AskSubject(Module), q.Ask(), q.ID) + _ = other.Publish(oc.AcceptSubjectAsk(Module), q.Ask(), q.ID) r.records[q.ID] = other.records[q.ID] _ = store.Put(key, slot{Question: q.ID, At: t0}) } diff --git a/modules/claude-code/cmd/claude-code/registrations.go b/modules/claude-code/cmd/claude-code/registrations.go index 0e275e2a0..6f3229894 100644 --- a/modules/claude-code/cmd/claude-code/registrations.go +++ b/modules/claude-code/cmd/claude-code/registrations.go @@ -96,7 +96,24 @@ func Registrations(p Paths, servers ServerState, config ConfigState) (map[string for _, it := range text { shared = append(shared, it.Kind+" "+it.Name) } + // The registered text an approved proposal replaced or removed on this node: what renders instead, or nothing. + replaced := []map[string]any{} + if approved, err := ReadApproved(p); err == nil { + for _, s := range ApprovedHere(approved, p.Node) { + if s.Over == "" { + continue + } + did := "replaced" + if s.Removes { + did = "removed" + } + replaced = append(replaced, map[string]any{"name": s.Name, "scope": s.Scope, "did": did, + "registered text": fingerprintOf(s.Over), "proposal": s.Proposal, "approved": s.Approved.Format("2006-01-02T15:04:05Z07:00"), + "by": s.By}) + } + } return map[string]any{ + "registered text replaced by an approved proposal, or removed by one": replaced, "set at the controller's terminal, as this node composes it": map[string]any{ "managed_settings": settings.ManagedSettings, "mcp_servers": settings.MCPServers, "shared text": shared, "shared text left out": leftOut}, diff --git a/modules/claude-code/cmd/claude-code/replace_registered_test.go b/modules/claude-code/cmd/claude-code/replace_registered_test.go new file mode 100644 index 000000000..d61fb1060 --- /dev/null +++ b/modules/claude-code/cmd/claude-code/replace_registered_test.go @@ -0,0 +1,317 @@ +package main + +import ( + "encoding/json" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" + "strings" + "testing" + "time" +) + +// A section registered for the mesh before the controller's terminal took that over, or set at the terminal, is +// replaced or removed by a proposal the operator approves (novox/hq ADR 0274, issue 340 note 14). + +const registeredOld = "# What this mesh is\n\n## Its purpose\n\nThe old words.\n" +const registeredNew = "# What this mesh is\n\n## Its purpose\n\nThe new words.\n" + +// register places the old text the way the node keeps it: in the registered items (mesh scope) or in the settings. +func register(t *testing.T, p Paths, where string) { + t.Helper() + switch where { + case "registered": + raw, _ := json.Marshal(map[string]Item{"mesh.instructions.what-this-mesh-is": {Kind: KindInstructions, + Name: "what-this-mesh-is", Scope: ScopeMesh, Files: one("what-this-mesh-is", registeredOld), By: "g14"}}) + writeFile(t, p.config(), string(raw)) + case "terminal": + raw, _ := json.Marshal(map[string]any{"instructions": map[string]string{"what-this-mesh-is": registeredOld}}) + writeFile(t, p.Settings, string(raw)) + } +} + +func TestARegisteredSectionIsReplacedOrRemovedOnTheOperatorsApproval(t *testing.T) { + for _, where := range []string{"registered", "terminal"} { + t.Run(where, func(t *testing.T) { + p, _ := node(t, "g14") + other, _ := node(t, "ace") + register(t, p, where) + register(t, other, where) + store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter() + propose := func(in ProposeInput, at time.Time) string { + t.Helper() + in.Scope, in.Name = ScopeMesh, "what-this-mesh-is" + out, err := Propose(p, in, store, r, at) + if err != nil { + t.Fatal(err) + } + return out["proposed"].(string) + } + // The same text is no change; a new section with such headings is still refused. + if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "what-this-mesh-is", Text: registeredOld}, store, r, t0); err == nil { + t.Error("the registered text was proposed over itself") + } + if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "fresh", Text: registeredNew}, store, r, t0); err == nil { + t.Error("a new section may hold the mesh's own headings") + } + + id := propose(ProposeInput{Text: registeredNew}, t0) + a := r.published[id] + if a.Headline != "Replace Claude instructions 'what-this-mesh-is'?" || + !strings.Contains(a.Explanation, "in place of the instructions 'what-this-mesh-is' registered before") { + t.Errorf("the ask does not say it replaces the registered section: %q / %q", a.Headline, a.Explanation) + } + if !strings.Contains(a.Details, "It replaces the registered instructions of this name, whose text has the fingerprint "+ + fingerprintOf(textDigest(registeredOld))) || !strings.Contains(a.Details, "The new words.") { + t.Errorf("the ask does not show the text and what it replaces: %q", a.Details) + } + if got := renderedInstructions(t, p); !strings.Contains(got, "The old words.") || strings.Contains(got, "The new words.") { + t.Fatal("a proposal changed what renders") + } + // Declined, nothing changes. + r.close(r.chose(id, OptionDecline, t0.Add(time.Hour))) + if changed, _, _ := Reconcile(p, store, r, nil, t0.Add(time.Hour)); changed { + t.Error("a decline changed what renders") + } + // Approved, the registered section is replaced in place, on every node that proves the warrant. + id = propose(ProposeInput{Text: registeredNew}, t0.Add(2*time.Hour)) + r.close(r.chose(id, OptionApprove, t0.Add(3*time.Hour))) + for _, n := range []Paths{p, other} { + if changed, _, err := Reconcile(n, store, r, nil, t0.Add(3*time.Hour)); err != nil || !changed { + t.Fatalf("%s: the approval changed nothing (%v)", n.Node, err) + } + got := renderedInstructions(t, n) + if strings.Contains(got, "The old words.") || strings.Count(got, "The new words.") != 1 || + strings.Count(got, "### what-this-mesh-is") != 1 { + t.Fatalf("%s: the section was not replaced once, in place:\n%s", n.Node, got) + } + if strings.Contains(got, "## Instructions the operator approved") { + t.Errorf("%s: a replacement is listed apart from the section it replaces", n.Node) + } + } + // claude_code_registrations and claude_code_proposals show it. + reg, err := Registrations(p, &bus{kept: map[string]map[string]any{}}, memConfig{}) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(reg) + if !strings.Contains(string(raw), "registered text replaced by an approved proposal") { + t.Errorf("claude_code_registrations does not show the replacement: %s", raw) + } + list, _ := ListProposals(p, store, r, "", t0.Add(4*time.Hour)) + raw, _ = json.Marshal(list) + if !strings.Contains(string(raw), "replaces the registered section whose text") { + t.Errorf("claude_code_proposals does not show the replacement: %s", raw) + } + + // A removal of the registered section is asked the same way and leaves nothing. + rid := propose(ProposeInput{Remove: true}, t0.Add(5*time.Hour)) + if got := r.published[rid]; got.Headline != "Remove Claude instructions 'what-this-mesh-is'?" { + t.Errorf("removal ask: %q", got.Headline) + } + r.close(r.chose(rid, OptionApprove, t0.Add(6*time.Hour))) + if changed, _, _ := Reconcile(p, store, r, nil, t0.Add(6*time.Hour)); !changed { + t.Fatal("the approved removal changed nothing") + } + if got := renderedInstructions(t, p); strings.Contains(got, "what-this-mesh-is") { + t.Errorf("the removal left the section:\n%s", got) + } + }) + } +} + +func TestARegisteredSectionIsRemovedByAProposalToo(t *testing.T) { + p, _ := node(t, "g14") + register(t, p, "registered") + store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter() + out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "what-this-mesh-is", Remove: true}, store, r, t0) + if err != nil { + t.Fatal(err) + } + id := out["proposed"].(string) + if !strings.Contains(r.published[id].Explanation, "registered") || !strings.Contains(r.published[id].Details, "registered text it removes") { + t.Errorf("the ask does not say it removes a registered section: %+v", r.published[id]) + } + r.close(r.chose(id, OptionApprove, t0.Add(time.Hour))) + if _, _, err := Reconcile(p, store, r, nil, t0.Add(time.Hour)); err != nil { + t.Fatal(err) + } + if got := renderedInstructions(t, p); strings.Contains(got, "The old words") { + t.Errorf("the registered section still renders:\n%s", got) + } + // Nothing registered and nothing approved: still refused. + if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "nothing-here", Remove: true}, store, r, t0); err == nil { + t.Error("a section nobody holds was removed") + } +} + +func TestAnApprovalIsBoundToTheRegisteredTextItReplaces(t *testing.T) { + prop := Proposal{ID: "instr-0123456789abcdef", Action: ActionReplace, Scope: ScopeMesh, Name: "x", Text: "t\n", Digest: textDigest("t\n"), + From: "g14", At: t0} + plain := prop.Act(OptionApprove) + if _, ok := plain["over"]; ok { + t.Error("a proposal over nothing binds an over: its older warrants would no longer hold") + } + prop.Over = textDigest("old\n") + if prop.Act(OptionApprove)["over"] != prop.Over { + t.Error("the act does not bind the registered text it replaces") + } + prop.Over = "not a digest" + if ProposalProblem(prop) == "" { + t.Error("a proposal over a text with no digest is one this module makes") + } +} + +func TestANodeSectionRegisteredForThisNodeIsReplacedOnlyThere(t *testing.T) { + p, _ := node(t, "g14") + raw, _ := json.Marshal(map[string]Item{"node.g14.instructions.local": {Kind: KindInstructions, Name: "local", Scope: ScopeNode, + Files: one("local", "## Local\n\nold\n"), By: "g14"}}) + writeFile(t, p.config(), string(raw)) + store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter() + out, err := Propose(p, ProposeInput{Scope: ScopeNode, Nodes: []string{"g14"}, Name: "local", Text: "## Local\n\nnew\n"}, store, r, t0) + if err != nil { + t.Fatal(err) + } + id := out["proposed"].(string) + r.close(r.chose(id, OptionApprove, t0.Add(time.Hour))) + if _, _, err := Reconcile(p, store, r, nil, t0.Add(time.Hour)); err != nil { + t.Fatal(err) + } + got := renderedInstructions(t, p) + if strings.Contains(got, "\nold\n") || !strings.Contains(got, "\nnew\n") || strings.Count(got, "### local") != 1 { + t.Errorf("the node's section was not replaced in place:\n%s", got) + } +} + +// The cases ADR 0642 cites, by name. +func TestRegisteredSectionEdgeCases(t *testing.T) { + const name = "what-this-mesh-is" + t.Run("TestANameRegisteredWithTwoTextsIsRefusedUntilOneIsUnregistered", func(t *testing.T) { + p, _ := node(t, "g14") + register(t, p, "registered") + raw, _ := json.Marshal(map[string]any{"instructions": map[string]string{name: registeredNew}}) + writeFile(t, p.Settings, string(raw)) + store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter() + in := ProposeInput{Scope: ScopeMesh, Name: name, Text: "A third text.\n"} + if _, err := Propose(p, in, store, r, t0); err == nil || !strings.Contains(err.Error(), "different texts") || + !strings.Contains(err.Error(), "claude_code_instructions_unregister, scope mesh, name "+name) || + !strings.Contains(err.Error(), "set at the controller's terminal") { + t.Fatalf("a name registered with two texts was proposed over: %v", err) + } + if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: name, Remove: true}, store, r, t0); err == nil { + t.Error("a name registered with two texts was removed") + } + writeFile(t, p.config(), "{}") // one of the two is unregistered + if _, err := Propose(p, in, store, r, t0); err != nil { + t.Errorf("refused after one was unregistered: %v", err) + } + }) + t.Run("TestRemovingAReplacementLeavesNeitherText", func(t *testing.T) { + p, _ := node(t, "g14") + register(t, p, "registered") + store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter() + approve := func(in ProposeInput, at time.Time) string { + in.Scope, in.Name = ScopeMesh, name + out, err := Propose(p, in, store, r, at) + if err != nil { + t.Fatal(err) + } + id := out["proposed"].(string) + r.close(r.chose(id, OptionApprove, at.Add(time.Minute))) + if _, _, err := Reconcile(p, store, r, nil, at.Add(time.Minute)); err != nil { + t.Fatal(err) + } + return id + } + approve(ProposeInput{Text: registeredNew}, t0) + rid := approve(ProposeInput{Remove: true}, t0.Add(time.Hour)) + if got := renderedInstructions(t, p); strings.Contains(got, "The old words.") || strings.Contains(got, "The new words.") { + t.Errorf("a text is left:\n%s", got) + } + // The ask names both fingerprints: the registered text's, and the replacement's that sessions read. + d := r.published[rid].Details + if !strings.Contains(d, "The registered text it removes has the fingerprint "+fingerprintOf(textDigest(registeredOld))) || + !strings.Contains(d, "has the fingerprint "+fingerprintOf(textDigest(registeredNew))) { + t.Errorf("the removal does not name both fingerprints: %q", d) + } + }) + t.Run("TestAChangedRegisteredTextOutlivesItsApproval", func(t *testing.T) { + p, _ := node(t, "g14") + register(t, p, "registered") + store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter() + out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: name, Text: registeredNew}, store, r, t0) + if err != nil { + t.Fatal(err) + } + id := out["proposed"].(string) + r.close(r.chose(id, OptionApprove, t0.Add(time.Hour))) + if _, _, err := Reconcile(p, store, r, nil, t0.Add(time.Hour)); err != nil { + t.Fatal(err) + } + // The registered text changes after the approval: the approval was bound to the old text, so it is not + // taken for the new one, which renders as registered. + raw, _ := json.Marshal(map[string]Item{"mesh.instructions." + name: {Kind: KindInstructions, Name: name, + Scope: ScopeMesh, Files: one(name, "A later registered text.\n"), By: "g14"}}) + writeFile(t, p.config(), string(raw)) + got := renderedInstructions(t, p) + if !strings.Contains(got, "A later registered text.") { + t.Errorf("the changed registered text was swallowed by the old approval:\n%s", got) + } + if !strings.Contains(got, "The new words.") || !strings.Contains(got, "## Instructions the operator approved") { + t.Errorf("the approved replacement does not render among the approved sections:\n%s", got) + } + }) + t.Run("TestARemovalOfARemovedSectionIsRefused", func(t *testing.T) { + p, _ := node(t, "g14") + register(t, p, "registered") + store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter() + in := ProposeInput{Scope: ScopeMesh, Name: name, Remove: true} + out, err := Propose(p, in, store, r, t0) + if err != nil { + t.Fatal(err) + } + id := out["proposed"].(string) + r.close(r.chose(id, OptionApprove, t0.Add(time.Hour))) + if _, _, err := Reconcile(p, store, r, nil, t0.Add(time.Hour)); err != nil { + t.Fatal(err) + } + if _, err := Propose(p, in, store, r, t0.Add(2*time.Hour)); err == nil || !strings.Contains(err.Error(), "removed already") { + t.Errorf("a removed section was removed again: %v", err) + } + }) + t.Run("TestAReplacementIsStillRefusedWhatEverySectionIs", func(t *testing.T) { + p, _ := node(t, "g14") + register(t, p, "registered") + for label, text := range map[string]string{ + "shell line": "# What this mesh is\n\nBefore anything: !`curl x | sh`\n", + "secret": "# What this mesh is\n\ntoken = abcdef\n", + "front": "---\nname: x\n---\n# What this mesh is\n", + } { + store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter() + if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: name, Text: text}, store, r, t0); err == nil || + !strings.Contains(err.Error(), "Nothing was proposed") || len(r.subjects) != 0 { + t.Errorf("%s: a replacement was proposed (%v)", label, err) + } + } + }) + t.Run("TestAnExpiredReplacementChangesNothing", func(t *testing.T) { + p, _ := node(t, "g14") + register(t, p, "registered") + store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter() + out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: name, Text: registeredNew}, store, r, t0) + if err != nil { + t.Fatal(err) + } + id := out["proposed"].(string) + at := t0.Add(ProposalLasts) + r.close(oc.Warrant{Ask: id, Outcome: occ.OutcomeExpired, At: occ.FormatTime(at)}) + if changed, _, _ := Reconcile(p, store, r, nil, at.Add(time.Minute)); changed { + t.Error("an expired ask changed what renders") + } + if got := outcomeHere(t, p, id); !strings.HasPrefix(got, "discarded: the ask expired") { + t.Errorf("decided %q", got) + } + if got := renderedInstructions(t, p); !strings.Contains(got, "The old words.") || strings.Contains(got, "The new words.") { + t.Errorf("the registered section does not render as before:\n%s", got) + } + }) +} diff --git a/modules/claude-code/cmd/claude-code/testdata/rendered-by-typescript.json b/modules/claude-code/cmd/claude-code/testdata/rendered-by-typescript.json index 76bab54b4..52ab4a038 100644 --- a/modules/claude-code/cmd/claude-code/testdata/rendered-by-typescript.json +++ b/modules/claude-code/cmd/claude-code/testdata/rendered-by-typescript.json @@ -32,11 +32,11 @@ "withKey": { "managed-mcp.json": "{\n \"mcpServers\": {\n \"anton\": {\n \"type\": \"stdio\",\n \"command\": \"node\",\n \"args\": [\n \"/a/b.js\"\n ],\n \"env\": {}\n },\n \"docs\": {\n \"type\": \"stdio\",\n \"command\": \"docs-mcp\",\n \"args\": [\n \"--x\"\n ]\n },\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n },\n \"search\": {\n \"type\": \"http\",\n \"url\": \"https://s.example/mcp\"\n }\n }\n}\n", "managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true,\n \"apiKeyHelper\": \"/state/api-key-helper\"\n}\n", - "CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** the laptop\n- The other nodes, their roles and what runs where: ask the placement seat (`mesh-placement.placement`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `.` (the mesh's own seats\n include `mesh-placement`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `/.`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n" + "CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** the laptop\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `.` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `/.`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n" }, "plain": { "managed-mcp.json": "{\n \"mcpServers\": {\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n }\n }\n}\n", "managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true\n}\n", - "CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** not stated — set it in this module's settings for the node\n- The other nodes, their roles and what runs where: ask the placement seat (`mesh-placement.placement`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `.` (the mesh's own seats\n include `mesh-placement`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `/.`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n" + "CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** not stated — set it in this module's settings for the node\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `.` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `/.`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n" } } \ No newline at end of file diff --git a/modules/claude-code/go.mod b/modules/claude-code/go.mod index 0d302dba3..7b9a5cfc8 100644 --- a/modules/claude-code/go.mod +++ b/modules/claude-code/go.mod @@ -2,15 +2,30 @@ module claude-code go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 - -require gopkg.in/yaml.v3 v3.0.1 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( - github.com/klauspost/compress v1.20.0 // indirect + git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect golang.org/x/crypto v0.57.0 // indirect golang.org/x/sys v0.48.0 // indirect ) + +require ( + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000 // indirect +) diff --git a/modules/claude-code/go.sum b/modules/claude-code/go.sum index 9f845fe75..5d2018ba9 100644 --- a/modules/claude-code/go.sum +++ b/modules/claude-code/go.sum @@ -1,7 +1,15 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= +github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= +github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= +github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= @@ -12,6 +20,8 @@ golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/modules/claude-code/module.json b/modules/claude-code/module.json index b1b0da23f..7bfbd71fd 100644 --- a/modules/claude-code/module.json +++ b/modules/claude-code/module.json @@ -23,7 +23,19 @@ "operator-channel" ], "invokes": [ - "seat:mesh-hand-acts.warranted" + "seat:mesh-hand-acts.warranted", + "seat:mesh-hand-acts.hand-act", + "seat:mesh-catalog.seat-definitions", + "mesh-delivery-indexer.indexer_read_modules", + "mesh-delivery-indexer.indexer_read_manifests", + "mesh-delivery-assigner.assigner_read_assignments" + ], + "consumes": [ + "mesh-delivery-indexer.views.modules", + "mesh-delivery-indexer.views.manifests", + "mesh-delivery-assigner.views.assignments", + "mesh-catalog.seat-definition-changed", + "mesh-catalog.seat-definition-removed" ], "state": [ "servers", @@ -32,13 +44,14 @@ "proposals", "questions", "memory", - "waits" + "waits", + "findings" + ], + "emits": [ + "judged" ], "reads": [ - "claude-licence-manager.bindings", - "mesh-delivery-assigner.placement", - "mesh-delivery-indexer.modules", - "mesh-delivery-indexer.manifests" + "claude-licence-manager.bindings" ], "tools": [ "claude_code_status", @@ -89,7 +102,11 @@ "claude_code_home_removed", "claude_code_home_restore", "claude_code_judge", - "claude_code_judged" + "claude_code_judged", + "claude_code_first_views", + "claude_code_first_view_again", + "claude_code_seat_list", + "claude_code_seat_list_again" ], "data": { "own": [ diff --git a/modules/claude-licence-manager/go.mod b/modules/claude-licence-manager/go.mod index ad85fa049..8d928ca73 100644 --- a/modules/claude-licence-manager/go.mod +++ b/modules/claude-licence-manager/go.mod @@ -3,7 +3,7 @@ module claude-licence-manager go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 github.com/jackc/pgx/v5 v5.11.0 ) @@ -11,7 +11,7 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/claude-licence-manager/go.sum b/modules/claude-licence-manager/go.sum index 7ab29444b..7475ceb70 100644 --- a/modules/claude-licence-manager/go.sum +++ b/modules/claude-licence-manager/go.sum @@ -1,5 +1,3 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -11,8 +9,8 @@ github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/clipmenu/go.mod b/modules/clipmenu/go.mod index b412d8ed6..12e9389fe 100644 --- a/modules/clipmenu/go.mod +++ b/modules/clipmenu/go.mod @@ -2,10 +2,10 @@ module clipmenu go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/clipmenu/go.sum b/modules/clipmenu/go.sum index 02d6daae7..0daef11ff 100644 --- a/modules/clipmenu/go.sum +++ b/modules/clipmenu/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/cups/go.mod b/modules/cups/go.mod index f060239cf..0a8d69f03 100644 --- a/modules/cups/go.mod +++ b/modules/cups/go.mod @@ -2,10 +2,10 @@ module cups go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/cups/go.sum b/modules/cups/go.sum index 02d6daae7..0daef11ff 100644 --- a/modules/cups/go.sum +++ b/modules/cups/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/dbus/go.mod b/modules/dbus/go.mod index 78f034506..613c7aa6a 100644 --- a/modules/dbus/go.mod +++ b/modules/dbus/go.mod @@ -3,12 +3,12 @@ module dbus go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 github.com/godbus/dbus/v5 v5.1.0 ) require ( - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/dbus/go.sum b/modules/dbus/go.sum index 0a96ebedd..34968e9e4 100644 --- a/modules/dbus/go.sum +++ b/modules/dbus/go.sum @@ -1,9 +1,7 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/desk-channel/cmd/desk-channel/desk.go b/modules/desk-channel/cmd/desk-channel/desk.go index e1fc3eb19..050d9635d 100644 --- a/modules/desk-channel/cmd/desk-channel/desk.go +++ b/modules/desk-channel/cmd/desk-channel/desk.go @@ -35,7 +35,8 @@ import ( "sync" "time" - "git.novox.be/novox/mesh-sdk/go/asks" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" ) // Kind is this channel's kind on both benches. @@ -59,17 +60,17 @@ const StandingEvery = 5 * time.Minute // Standing is what the desk says of itself: the bench's word, and when a named machine's notifier last lost // the answers its notifications waited for (its bundle started again). The router then says again what is -// still open and unanswered (issue 384). It belongs in asks.Standing once the SDK carries it; until then the +// still open and unanswered (issue 384). It belongs in oc.Standing once the SDK carries it; until then the // field travels as JSON the router reads under the same name. Zero (as "0001-01-01T00:00:00Z") until then. type Standing struct { - asks.Standing + oc.Standing AnswersLostAt time.Time `json:"answers-lost-at"` } // Shown is one of the router's messages as the desk shows it: the message, and its notification on each // machine. type Shown struct { - Message asks.Message `json:"message"` + Message oc.Message `json:"message"` On map[string]int `json:"on"` Sent time.Time `json:"sent"` } @@ -146,7 +147,7 @@ func (d *Desk) Take(subject string, body json.RawMessage) error { d.Logf("[desk-channel] work on %s is not this kind's; taken and dropped", subject) return nil } - var m asks.Message + var m oc.Message if err := json.Unmarshal(body, &m); err != nil || m.Handle == "" { d.Logf("[desk-channel] work on %s could not be read; taken and dropped", subject) return nil @@ -158,8 +159,8 @@ func (d *Desk) Take(subject string, body json.RawMessage) error { // would appear later, to whoever is at the screen then. seeing, why := d.seeing() if len(seeing) == 0 { - f := asks.Failed{Handle: m.Handle, Why: "not shown: " + why, At: d.Now()} - return d.Publish(asks.IntakeSubject(asks.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10)) + f := oc.Failed{Handle: m.Handle, Why: "not shown: " + why, At: d.Now()} + return d.Publish(occ.IntakeSubject(occ.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10)) } machines = seeing } @@ -176,7 +177,7 @@ func (d *Desk) Take(subject string, body json.RawMessage) error { // shown (a reminder, an escalation), replace the notification it had on each machine — dunst's replace id — // so a problem is one notification updated in place, never one more beside it. An edit stays quiet where // it was shown; a message said again is shown anew where it was not. - edit := parts[4] == asks.Edit + edit := parts[4] == occ.Edit var before *Shown switch { case edit: @@ -226,8 +227,8 @@ func (d *Desk) Take(subject string, body json.RawMessage) error { if len(machines) == 0 { why = "no machine to show it on: the setting desktop-machines names none" } - f := asks.Failed{Handle: m.Handle, Why: why, At: d.Now()} - if err := d.Publish(asks.IntakeSubject(asks.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10)); err != nil { + f := oc.Failed{Handle: m.Handle, Why: why, At: d.Now()} + if err := d.Publish(occ.IntakeSubject(occ.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10)); err != nil { return err } return nil @@ -322,7 +323,7 @@ func sameShown(a, b Shown) bool { } // show puts one message on one machine's notifier, in place of `replace` when it names one. -func (d *Desk) show(machine string, m asks.Message, replace int) (int, error) { +func (d *Desk) show(machine string, m oc.Message, replace int) (int, error) { urgency := "normal" switch { case m.Urgent: @@ -376,12 +377,12 @@ func (d *Desk) Chosen(event, machine, ticket string) { } handle, shown := d.find(ticket) if shown == nil { - _, _ = d.show(machine, asks.Message{Title: "That answer was not taken", + _, _ = d.show(machine, oc.Message{Title: "That answer was not taken", Body: "It belongs to no message shown here any more. Nothing was done.", Silent: true}, 0) return } if _, here := shown.On[machine]; !here { - _, _ = d.show(machine, asks.Message{Title: "That answer was not taken", + _, _ = d.show(machine, oc.Message{Title: "That answer was not taken", Body: "It was not shown on " + machine + ". Nothing was done.", Silent: true}, 0) return } @@ -395,11 +396,11 @@ func (d *Desk) Chosen(event, machine, ticket string) { } d.clickedOn[id] = machine d.mu.Unlock() - c := asks.Choice{ID: id, Ticket: ticket, Handle: handle, At: d.Now(), - Sender: asks.Sender{Identity: machine, Display: machine, Authenticated: false}} - if err := d.Publish(asks.IntakeSubject(asks.Chosen, Kind), c, id); err != nil { + c := oc.Choice{ID: id, Ticket: ticket, Handle: handle, At: d.Now(), + Sender: oc.Sender{Identity: machine, Display: machine, Authenticated: false}} + if err := d.Publish(occ.IntakeSubject(occ.Chosen, Kind), c, id); err != nil { d.Logf("[desk-channel] a choice could not be said to the router: %v", err) - _, _ = d.show(machine, asks.Message{Title: "That answer could not be taken now", + _, _ = d.show(machine, oc.Message{Title: "That answer could not be taken now", Body: "The mesh did not take it. Try again in a minute.", Silent: true}, 0) } } @@ -508,7 +509,7 @@ func (d *Desk) seeing() ([]string, string) { // (the review of 2026-10-09). The router shows no link's code while the desk is not ready. func (d *Desk) Standing() Standing { d.init() - s := Standing{Standing: asks.Standing{At: d.Now()}, AnswersLostAt: d.answersLostAt()} + s := Standing{Standing: oc.Standing{At: d.Now()}, AnswersLostAt: d.answersLostAt()} if _, why := d.seeing(); why != "" { s.Why = why return s @@ -542,7 +543,7 @@ func (d *Desk) SayStanding() { if same { return } - if err := d.Publish(asks.IntakeSubject(asks.StandingWhat, Kind), s, "standing."+strconv.FormatInt(s.At.UnixNano(), 10)); err != nil { + if err := d.Publish(occ.IntakeSubject(occ.StandingWhat, Kind), s, "standing."+strconv.FormatInt(s.At.UnixNano(), 10)); err != nil { d.Logf("[desk-channel] could not say whether it can send: %v", err) return } diff --git a/modules/desk-channel/cmd/desk-channel/desk_test.go b/modules/desk-channel/cmd/desk-channel/desk_test.go index 336f4d037..e41b4cf64 100644 --- a/modules/desk-channel/cmd/desk-channel/desk_test.go +++ b/modules/desk-channel/cmd/desk-channel/desk_test.go @@ -10,7 +10,8 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/asks" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" ) type memShown map[string]Shown @@ -62,10 +63,10 @@ func newDesk(t *testing.T, machines ...string) (*Desk, *[]notified, map[string][ return d, &shown, said, store } -func take(t *testing.T, d *Desk, verb string, m asks.Message) { +func take(t *testing.T, d *Desk, verb string, m oc.Message) { t.Helper() raw, _ := json.Marshal(m) - if err := d.Take(asks.ChannelSubject(verb, Kind), raw); err != nil { + if err := d.Take(occ.ChannelSubject(verb, Kind), raw); err != nil { t.Fatal(err) } } @@ -74,16 +75,16 @@ func take(t *testing.T, d *Desk, verb string, m asks.Message) { // says a click as a choice the router judges — only on a machine the message was shown on. func TestAClickOnAMachineTheMessageWasShownOnIsSaidToTheRouter(t *testing.T) { d, shown, said, _ := newDesk(t, "laptop", "away-machine") - take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "3 failed services", Body: "Needs you: them.", - Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}}) + take(t, d, occ.Show, oc.Message{Handle: "m1", Title: "3 failed services", Body: "Needs you: them.", + Buttons: []oc.Button{{Label: "Silence for a week", Ticket: "t1"}}}) if len(*shown) != 1 || (*shown)[0].machine != "laptop" || (*shown)[0].args["expire_ms"] != 0 || (*shown)[0].args["body"] != "Needs you: <mend> them." { t.Fatalf("shown %+v", *shown) } d.Chosen("e1", "laptop", "t1") d.Chosen("e1", "laptop", "t1") // a redelivery - got := said[asks.IntakeSubject(asks.Chosen, Kind)] - var c asks.Choice + got := said[occ.IntakeSubject(occ.Chosen, Kind)] + var c oc.Choice if len(got) != 1 || json.Unmarshal(got[0], &c) != nil || c.Ticket != "t1" || c.Handle != "m1" || c.Sender.Identity != "laptop" || c.Sender.Authenticated { t.Fatalf("said %s", got) @@ -91,14 +92,14 @@ func TestAClickOnAMachineTheMessageWasShownOnIsSaidToTheRouter(t *testing.T) { // Elsewhere it is refused, there, and nothing is said to the router. d.Chosen("e2", "workstation", "t1") d.Chosen("e3", "laptop", "forged") - if len(said[asks.IntakeSubject(asks.Chosen, Kind)]) != 1 { + if len(said[occ.IntakeSubject(occ.Chosen, Kind)]) != 1 { t.Error("a click not shown there was said") } if last := (*shown)[len(*shown)-1]; last.machine != "laptop" || last.args["summary"] != "That answer was not taken" { t.Errorf("the refusal was not shown where it was chosen: %+v", last) } // The router's reply to the click is shown on the machine it was made on. - take(t, d, asks.Send, asks.Message{Handle: "r1", Title: "Details: 3 failed services", Reply: c.ID}) + take(t, d, occ.Send, oc.Message{Handle: "r1", Title: "Details: 3 failed services", Reply: c.ID}) if last := (*shown)[len(*shown)-1]; last.machine != "laptop" || last.args["summary"] != "Details: 3 failed services" { t.Errorf("%+v", last) } @@ -107,8 +108,8 @@ func TestAClickOnAMachineTheMessageWasShownOnIsSaidToTheRouter(t *testing.T) { // The note follows the body after a blank line: the desk has no smaller text, and leaves nothing out (issue 639). func TestTheNoteFollowsTheBody(t *testing.T) { d, shown, _, _ := newDesk(t, "laptop") - take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "q", Body: "The summary.", Note: "Asked by mesh-knowledge at 16:05.", - Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}}) + take(t, d, occ.Show, oc.Message{Handle: "m1", Title: "q", Body: "The summary.", Note: "Asked by mesh-knowledge at 16:05.", + Buttons: []oc.Button{{Label: "Silence for a week", Ticket: "t1"}}}) if last := (*shown)[len(*shown)-1]; last.args["body"] != "The summary.\n\nAsked by mesh-knowledge at 16:05." { t.Errorf("the body: %q", last.args["body"]) } @@ -116,8 +117,8 @@ func TestTheNoteFollowsTheBody(t *testing.T) { func TestAnEditReplacesTheNotification(t *testing.T) { d, shown, _, _ := newDesk(t, "laptop") - take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "q", Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}}) - take(t, d, asks.Edit, asks.Message{Handle: "m1", Title: "Silence for a week: chosen"}) + take(t, d, occ.Show, oc.Message{Handle: "m1", Title: "q", Buttons: []oc.Button{{Label: "Silence for a week", Ticket: "t1"}}}) + take(t, d, occ.Edit, oc.Message{Handle: "m1", Title: "Silence for a week: chosen"}) if last := (*shown)[len(*shown)-1]; last.args["replace_id"] != 1 || last.args["actions"] != nil { t.Errorf("the edit: %+v", last.args) } @@ -134,17 +135,17 @@ func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) { d, shown, said, store := newDesk(t, "laptop") d.Paused("laptop", false) d.SayStanding() - take(t, d, asks.Show, asks.Message{Handle: "open", Title: "2 failed services on shanks", - Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-open"}}}) - take(t, d, asks.Show, asks.Message{Handle: "silenced", Title: "Agent-home shrank", - Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-silenced"}}}) - take(t, d, asks.Edit, asks.Message{Handle: "silenced", Title: "Silenced until Saturday: Agent-home shrank", Silent: true}) - take(t, d, asks.Show, asks.Message{Handle: "resolved", Title: "Questions for you not delivered", - Buttons: []asks.Button{{Label: "Details", Ticket: "t-resolved"}}}) - take(t, d, asks.Edit, asks.Message{Handle: "resolved", Title: "Resolved: questions for you are delivered", Silent: true}) + take(t, d, occ.Show, oc.Message{Handle: "open", Title: "2 failed services on shanks", + Buttons: []oc.Button{{Label: "Silence for a week", Ticket: "t-open"}}}) + take(t, d, occ.Show, oc.Message{Handle: "silenced", Title: "Agent-home shrank", + Buttons: []oc.Button{{Label: "Silence for a week", Ticket: "t-silenced"}}}) + take(t, d, occ.Edit, oc.Message{Handle: "silenced", Title: "Silenced until Saturday: Agent-home shrank", Silent: true}) + take(t, d, occ.Show, oc.Message{Handle: "resolved", Title: "Questions for you not delivered", + Buttons: []oc.Button{{Label: "Details", Ticket: "t-resolved"}}}) + take(t, d, occ.Edit, oc.Message{Handle: "resolved", Title: "Resolved: questions for you are delivered", Silent: true}) openID := store["open"].On["laptop"] before := len(*shown) - standings := len(said[asks.IntakeSubject(asks.StandingWhat, Kind)]) + standings := len(said[occ.IntakeSubject(occ.StandingWhat, Kind)]) later := d.Now().Add(time.Hour) d.Now = func() time.Time { return later } d.Paused("laptop", false) @@ -157,25 +158,25 @@ func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) { if store["open"].On["laptop"] != openID { t.Fatalf("the open message's notification was forgotten: %+v", store["open"].On) } - got := said[asks.IntakeSubject(asks.StandingWhat, Kind)] + got := said[occ.IntakeSubject(occ.StandingWhat, Kind)] var s Standing if len(got) != standings+1 || json.Unmarshal(got[len(got)-1], &s) != nil || !s.Ready || !s.AnswersLostAt.Equal(later) { t.Fatalf("the standing after the start: %d said, last %s", len(got)-standings, got[len(got)-1]) } // The router says the open one again under its handle: in place of its notification, with its answers. - take(t, d, asks.Show, asks.Message{Handle: "open", Title: "2 failed services on shanks", - Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-open"}}}) + take(t, d, occ.Show, oc.Message{Handle: "open", Title: "2 failed services on shanks", + Buttons: []oc.Button{{Label: "Silence for a week", Ticket: "t-open"}}}) last := (*shown)[len(*shown)-1] if len(*shown) != before+1 || last.args["replace_id"] != openID || last.args["actions"] == nil { t.Fatalf("the open message said again: %+v", last.args) } d.Chosen("e2", "laptop", "t-open") - if len(said[asks.IntakeSubject(asks.Chosen, Kind)]) != 1 { + if len(said[occ.IntakeSubject(occ.Chosen, Kind)]) != 1 { t.Error("a click on the message said again was not said to the router") } // The same start event again (a redelivery) does nothing more. - standings = len(said[asks.IntakeSubject(asks.StandingWhat, Kind)]) - if n := d.Started("e-start", "laptop"); n != 0 || len(said[asks.IntakeSubject(asks.StandingWhat, Kind)]) != standings { + standings = len(said[occ.IntakeSubject(occ.StandingWhat, Kind)]) + if n := d.Started("e-start", "laptop"); n != 0 || len(said[occ.IntakeSubject(occ.StandingWhat, Kind)]) != standings { t.Errorf("a redelivered start was acted on again: %d", n) } // A desk none of whose notifiers started again says a zero time, which the router reads as none. @@ -187,8 +188,8 @@ func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) { func TestNoMachineThatShowsItIsAFailureSaidToTheRouter(t *testing.T) { d, _, said, _ := newDesk(t, "away-machine") - take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "x"}) - if f := said[asks.IntakeSubject(asks.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "no session answers") || + take(t, d, occ.Send, oc.Message{Handle: "m1", Title: "x"}) + if f := said[occ.IntakeSubject(occ.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "no session answers") || strings.Contains(string(f[0]), `"permanent":true`) { t.Errorf("%s", f) } @@ -229,7 +230,7 @@ func TestItPromisesNoVerifiedSender(t *testing.T) { func TestASecretMessageIsShownAndNotKept(t *testing.T) { d, shown, _, store := newDesk(t, "laptop") d.Paused("laptop", false) - take(t, d, asks.Send, asks.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true}) + take(t, d, occ.Send, oc.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true}) if len(*shown) != 1 { t.Fatal("not shown") } @@ -252,8 +253,8 @@ func TestASecretIsShownOnlyWhereTheNotifierIsHeardUnpausedAndNeverKept(t *testin if s := d.Standing(); !s.Ready { t.Fatalf("not ready with laptop heard unpaused: %+v", s) } - code := asks.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true} - take(t, d, asks.Send, code) + code := oc.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true} + take(t, d, occ.Send, code) if len(*shown) != 1 || (*shown)[0].machine != "laptop" || (*shown)[0].args["secret"] != true { t.Fatalf("the code was shown as %+v", *shown) } @@ -262,11 +263,11 @@ func TestASecretIsShownOnlyWhereTheNotifierIsHeardUnpausedAndNeverKept(t *testin } // Paused: not shown anywhere, said as failed, and the desk is not ready. d.Paused("laptop", true) - take(t, d, asks.Send, code) + take(t, d, occ.Send, code) if len(*shown) != 1 { t.Fatalf("the code was shown on a paused notifier: %+v", *shown) } - if f := said[asks.IntakeSubject(asks.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "paused") { + if f := said[occ.IntakeSubject(occ.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "paused") { t.Errorf("not said as failed: %s", f) } if s := d.Standing(); s.Ready || !strings.Contains(s.Why, "paused") { @@ -284,7 +285,7 @@ func TestASecretIsShownOnlyWhereTheNotifierIsHeardUnpausedAndNeverKept(t *testin t.Error("a machine not named made the desk ready") } // A message that is not secret still goes to every named machine, paused or not. - take(t, d, asks.Send, asks.Message{Handle: "m9", Title: "x"}) + take(t, d, occ.Send, oc.Message{Handle: "m9", Title: "x"}) if len(*shown) != 3 { t.Errorf("an ordinary message was held back: %+v", *shown) } @@ -330,8 +331,8 @@ func TestItRunsAsItsOwnAccount(t *testing.T) { // was not shown; a message under a new handle replaces nothing. func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) { d, shown, _, store := newDesk(t, "laptop", "desktop") - take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "2 failed services on shanks"}) - take(t, d, asks.Send, asks.Message{Handle: "m2", Title: "another problem"}) + take(t, d, occ.Send, oc.Message{Handle: "m1", Title: "2 failed services on shanks"}) + take(t, d, occ.Send, oc.Message{Handle: "m2", Title: "another problem"}) if len(*shown) != 4 { t.Fatalf("shown %d", len(*shown)) } @@ -343,8 +344,8 @@ func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) { first := store["m1"].On // The desk now names a third machine as well. d.Machines = func() []string { return []string{"laptop", "desktop", "third"} } - take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks", Urgent: true, - Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}}) + take(t, d, occ.Show, oc.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks", Urgent: true, + Buttons: []oc.Button{{Label: "Silence for a week", Ticket: "t1"}}}) again := (*shown)[4:] if len(again) != 3 { t.Fatalf("said again on %d machines", len(again)) @@ -397,18 +398,18 @@ func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) { d, shown, _, store := newDesk(t, "laptop") down := true d.Store = downStore{store, &down} - raised, _ := json.Marshal(asks.Message{Handle: "m9", Title: "A new build cannot be delivered", Urgent: true}) - if err := d.Take(asks.ChannelSubject(asks.Send, Kind), raised); err != nil { + raised, _ := json.Marshal(oc.Message{Handle: "m9", Title: "A new build cannot be delivered", Urgent: true}) + if err := d.Take(occ.ChannelSubject(occ.Send, Kind), raised); err != nil { t.Fatalf("the raise was not taken: %v", err) } - cleared, _ := json.Marshal(asks.Message{Handle: "m9", Title: "Cleared: a new build can be delivered"}) - if err := d.Take(asks.ChannelSubject(asks.Edit, Kind), cleared); err != nil { + cleared, _ := json.Marshal(oc.Message{Handle: "m9", Title: "Cleared: a new build can be delivered"}) + if err := d.Take(occ.ChannelSubject(occ.Edit, Kind), cleared); err != nil { t.Fatalf("the clearing was not taken: %v", err) } d.KeepUnkept() // the minute's standing, store still down down = !back d.KeepUnkept() - take(t, d, asks.Send, asks.Message{Handle: "m10", Title: "another"}) + take(t, d, occ.Send, oc.Message{Handle: "m10", Title: "another"}) if len(*shown) != 3 || (*shown)[1].args["replace_id"] != 1 || (*shown)[1].args["summary"] != "Cleared: a new build can be delivered" { t.Fatalf("the clearing did not replace the urgent notification: %+v", *shown) } @@ -423,7 +424,7 @@ func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) { } d, shown, said, store := newDesk(t, "laptop", "desktop") - take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "2 failed services on shanks"}) + take(t, d, occ.Send, oc.Message{Handle: "m1", Title: "2 failed services on shanks"}) first := store["m1"].On notify := d.Notify d.Notify = func(machine string, args map[string]any) (json.RawMessage, error) { @@ -432,13 +433,13 @@ func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) { } return notify(machine, args) } - take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks"}) + take(t, d, occ.Send, oc.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks"}) if got := store["m1"].On["desktop"]; got != first["desktop"] { t.Fatalf("the desktop's notification %d was forgotten: %v", first["desktop"], store["m1"].On) } d.Notify = func(string, map[string]any) (json.RawMessage, error) { return nil, errors.New("away") } - take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "Now urgent: 2 failed services on shanks"}) - if len(said[asks.IntakeSubject(asks.FailedWhat, Kind)]) != 1 { + take(t, d, occ.Send, oc.Message{Handle: "m1", Title: "Now urgent: 2 failed services on shanks"}) + if len(said[occ.IntakeSubject(occ.FailedWhat, Kind)]) != 1 { t.Fatalf("shown nowhere, and not said as failed") } _ = shown diff --git a/modules/desk-channel/go.mod b/modules/desk-channel/go.mod index 3ee5c902d..ba34bb473 100644 --- a/modules/desk-channel/go.mod +++ b/modules/desk-channel/go.mod @@ -2,13 +2,18 @@ module deskchannel go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect golang.org/x/crypto v0.57.0 // indirect golang.org/x/sys v0.48.0 // indirect ) + +require ( + git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000 +) diff --git a/modules/desk-channel/go.sum b/modules/desk-channel/go.sum index 02d6daae7..0daef11ff 100644 --- a/modules/desk-channel/go.sum +++ b/modules/desk-channel/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/disk-load/cmd/disk-load/data.go b/modules/disk-load/cmd/disk-load/data.go index 7882c8040..19eb6597c 100644 --- a/modules/disk-load/cmd/disk-load/data.go +++ b/modules/disk-load/cmd/disk-load/data.go @@ -1,9 +1,9 @@ package main // Where disk-load learns the data items it measures (novox/hq ADR 0577 clause 2): the node-engine's state data -// (delivery.EngineDataBucket), one key `.` per module placed on this node that declares data items, as the +// (engineDataBucket), one key `.` per module placed on this node that declares data items, as the // engine composed them. disk-load watches its own node's keys and composes nothing: it reads no resolved values, no -// manifests and no placement, so a manifest its mesh-sdk cannot judge never silences it, and a round reads one watched +// manifests and no assignments, so a manifest its mesh-sdk cannot judge never silences it, and a round reads one watched // map instead of several hundred keys over the tunnel (issue 1955). import ( @@ -12,11 +12,30 @@ import ( "fmt" "sync" + engineclient "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" ) +// The node-engine's state data as the bus names it: the seat's local name under its holder (the one name of the holder +// here, held equal to the manifest's `reads` by a unit row). +const ( + engineHolder = "mesh-host" + engineDataBucket = engineHolder + "_" + nodeengine.StateData + engineDataRead = engineHolder + "." + nodeengine.StateData +) + +// itemsOf are the seat's data items as this module measures them. +func itemsOf(in []nodeengine.PlacedDataItem) []catalogue.PlacedDataItem { + out := make([]catalogue.PlacedDataItem, 0, len(in)) + for _, it := range in { + out = append(out, catalogue.PlacedDataItem{Module: it.Module, ID: it.Item, Class: it.Class, Path: it.Path, + CoveredBy: it.CoveredBy, Protection: it.Protection, Measure: it.Measure}) + } + return out +} + // Watched is this node's data items as the engine's state data holds them, by module, kept from a watch of its keys. type Watched struct { Node string @@ -29,7 +48,7 @@ type Watched struct { // ErrNotWatchedYet is a round asked before the watch handed over the engine's current keys: no item is known yet, and // none is taken for gone. -var ErrNotWatchedYet = errors.New("the node-engine's state " + delivery.EngineDataBucket + " is not watched yet: " + +var ErrNotWatchedYet = errors.New("the node-engine's state " + engineDataBucket + " is not watched yet: " + "no data item of this machine is known") // Take keeps one change of a key and answers the module it is of and whether its items changed. @@ -48,21 +67,22 @@ func (w *Watched) Take(ch stdio.StateChange) (string, bool, error) { delete(w.byModule, module) return module, true, nil } - d, err := delivery.DecodeEngineData(ch.Value) + d, err := engineclient.DecodeData(ch.Value) if err != nil { return "", false, fmt.Errorf("%s: %w", ch.Key, err) } if d.Node != w.Node { return "", false, fmt.Errorf("%s names the machine %q, and this is %s", ch.Key, d.Node, w.Node) } - for _, it := range d.Items { + items := itemsOf(d.Items) + for _, it := range items { if it.Module != d.Module { return "", false, fmt.Errorf("%s holds an item of %q, and is %s's", ch.Key, it.Module, d.Module) } } - changed := !sameItems(w.byModule[d.Module], d.Items) + changed := !sameItems(w.byModule[d.Module], items) w.byKey[ch.Key] = d.Module - w.byModule[d.Module] = d.Items + w.byModule[d.Module] = items return d.Module, changed, nil } @@ -102,11 +122,11 @@ func sameItems(a, b []catalogue.PlacedDataItem) bool { // Follow watches this node's keys of the engine's state data, and asks the keeper to measure a module whose items changed // after the watch began; it returns once the current keys were handed over (stdio.KeptState.Follow). func (w *Watched) Follow(k *Keeper) error { - pattern, err := delivery.EngineDataNodeKeys(w.Node) + pattern, err := engineclient.DataNodeKeys(w.Node) if err != nil { return err } - stdio.State(delivery.EngineDataRead).Follow(pattern, func(ch stdio.StateChange) error { + stdio.State(engineDataRead).Follow(pattern, func(ch stdio.StateChange) error { module, changed, err := w.Take(ch) if err != nil || !changed || ch.Current { return err diff --git a/modules/disk-load/cmd/disk-load/data_test.go b/modules/disk-load/cmd/disk-load/data_test.go index 10cbfe95b..ba748f02b 100644 --- a/modules/disk-load/cmd/disk-load/data_test.go +++ b/modules/disk-load/cmd/disk-load/data_test.go @@ -9,10 +9,11 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-catalog/modules/disk-load/diskreadings" + engineclient "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/health" ) // ADR 0577 clauses 2, 3 (and the disk-load check of clause 5): the items measured come from the node-engine's keys of @@ -20,9 +21,9 @@ import ( // that changes after the watch began has its module measured again, and one deleted is measured no more; an item whose // measurement fails is a failed item of the round, which measures the rest, and the round's key says so. func TestTheItemsComeFromTheEnginesKeysAndARoundSaysHowFarItGot(t *testing.T) { - key := func(module string) string { k, _ := delivery.EngineDataKey("ace", module); return k } + key := func(module string) string { k, _ := engineclient.DataKey("ace", module); return k } value := func(module string, items ...catalogue.PlacedDataItem) json.RawMessage { - raw, err := delivery.EncodeEngineData(delivery.EngineData{Node: "ace", Module: module, Digest: "sha256:" + module, Items: items}) + raw, err := engineclient.EncodeData(nodeengine.DataByNodeModuleValue{V: 1, Node: "ace", Module: module, Digest: "sha256:" + module, Items: seatItems(items)}) if err != nil { t.Fatal(err) } @@ -137,7 +138,7 @@ func TestTheItemsComeFromTheEnginesKeysAndARoundSaysHowFarItGot(t *testing.T) { // A restart: oldie's key was deleted while the watch was down, so no delete arrives. Once the watch handed over its // keys, oldie's reading is retired and pg's kept; an empty set retires nothing (the engine has not written yet). - _ = readings.Put("ace.oldie.x", health.Reading{V: health.ReadingVersion, Node: "ace", + _ = readings.Put("ace.oldie.x", diskreadings.Reading{V: diskreadings.ReadingVersion, Node: "ace", PlacedDataItem: catalogue.PlacedDataItem{Module: "oldie", ID: "x", Path: "/srv/oldie"}}) for _, restart := range []struct { name string @@ -167,12 +168,12 @@ func TestTheItemsComeFromTheEnginesKeysAndARoundSaysHowFarItGot(t *testing.T) { } } -func readingOf(s *memStore, key string) *health.Reading { +func readingOf(s *memStore, key string) *diskreadings.Reading { raw, _ := s.Get(key) if raw == nil { return nil } - r, err := health.DecodeReading(raw) + r, err := diskreadings.DecodeReading(raw) if err != nil { return nil } @@ -188,3 +189,13 @@ func waitFor(t *testing.T, done func() bool) { } t.Fatal("not done within 30 s") } + +// seatItems are measured items as the node-engine's state data keeps them. +func seatItems(in []catalogue.PlacedDataItem) []nodeengine.PlacedDataItem { + out := make([]nodeengine.PlacedDataItem, 0, len(in)) + for _, it := range in { + out = append(out, nodeengine.PlacedDataItem{Module: it.Module, Item: it.ID, Class: it.Class, Path: it.Path, + CoveredBy: it.CoveredBy, Protection: it.Protection, Measure: it.Measure}) + } + return out +} diff --git a/modules/disk-load/cmd/disk-load/keeper.go b/modules/disk-load/cmd/disk-load/keeper.go index 95998f3f5..7f48cfe61 100644 --- a/modules/disk-load/cmd/disk-load/keeper.go +++ b/modules/disk-load/cmd/disk-load/keeper.go @@ -6,9 +6,8 @@ import ( "encoding/json" "errors" "fmt" + "git.novox.be/novox/mesh-catalog/modules/disk-load/diskreadings" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/health" "os" "os/exec" "sort" @@ -287,7 +286,7 @@ func (k *Keeper) MeasureModule(ctx context.Context, module string) { // asked only on a change of that module's key (novox/hq ADR 0577 clause 2), never from a round. It is cleared when the // item is declared again (measureOne). func (k *Keeper) retireGone(module string, declared []catalogue.PlacedDataItem) (int, error) { - return k.retire(func(r health.Reading) bool { return r.Module == module }, declared) + return k.retire(func(r diskreadings.Reading) bool { return r.Module == module }, declared) } // RetireUnkeyed retires, once the watch handed over every current key, the readings of this node whose module has no @@ -303,14 +302,14 @@ func (k *Keeper) RetireUnkeyed(ctx context.Context) { return } if len(items) == 0 { - k.Say("the node-engine's state %s holds no key of this machine yet: no reading is retired for it", delivery.EngineDataBucket) + k.Say("the node-engine's state %s holds no key of this machine yet: no reading is retired for it", engineDataBucket) return } var declared []catalogue.PlacedDataItem for _, its := range items { declared = append(declared, its...) } - n, err := k.retire(func(r health.Reading) bool { _, keyed := items[r.Module]; return !keyed }, declared) + n, err := k.retire(func(r diskreadings.Reading) bool { _, keyed := items[r.Module]; return !keyed }, declared) switch { case err != nil: k.Say("the readings of modules with no key are not all retired: %v", err) @@ -320,14 +319,14 @@ func (k *Keeper) RetireUnkeyed(ctx context.Context) { } // retire marks retired every reading of this node that of picks and declared does not name. -func (k *Keeper) retire(of func(health.Reading) bool, declared []catalogue.PlacedDataItem) (int, error) { +func (k *Keeper) retire(of func(diskreadings.Reading) bool, declared []catalogue.PlacedDataItem) (int, error) { keys, err := k.Store.Keys() if err != nil { return 0, err } declaredNow := map[string]bool{} for _, it := range declared { - declaredNow[health.ReadingKey(k.Node, it.Module, it.ID)] = true + declaredNow[diskreadings.ReadingKey(k.Node, it.Module, it.ID)] = true } now := k.Measurer.Now().UTC() n := 0 @@ -339,7 +338,7 @@ func (k *Keeper) retire(of func(health.Reading) bool, declared []catalogue.Place if err != nil || len(raw) == 0 { continue } - r, err := health.DecodeReading(raw) + r, err := diskreadings.DecodeReading(raw) if err != nil || r.Node != k.Node || !of(r) || !r.RetiredAt.IsZero() { continue } @@ -357,14 +356,14 @@ func (k *Keeper) retire(of func(health.Reading) bool, declared []catalogue.Place // when it changed. It answers whether it wrote the reading, what the measurement said failed ("" when it did not, or // when the item was not measured now), and an error when the reading could not be read or kept. func (k *Keeper) measureOne(ctx context.Context, it catalogue.PlacedDataItem, walks bool) (bool, string, error) { - key := health.ReadingKey(k.Node, it.Module, it.ID) - var prev *health.Reading + key := diskreadings.ReadingKey(k.Node, it.Module, it.ID) + var prev *diskreadings.Reading raw, err := k.Store.Get(key) if err != nil { return false, "", err } if len(raw) > 0 { - if p, err := health.DecodeReading(raw); err == nil { + if p, err := diskreadings.DecodeReading(raw); err == nil { prev = &p } } @@ -374,7 +373,7 @@ func (k *Keeper) measureOne(ctx context.Context, it catalogue.PlacedDataItem, wa return false, "", nil } m := k.Measurer.Measure(ctx, it) - next, changed := health.NextReading(prev, k.Node, it, m, now) + next, changed := diskreadings.NextReading(prev, k.Node, it, m, now) if prev != nil && !prev.RetiredAt.IsZero() { // Declared again: it is no longer retired. next.RetiredAt, next.RetiredWhy, changed = time.Time{}, "", true @@ -386,32 +385,32 @@ func (k *Keeper) measureOne(ctx context.Context, it catalogue.PlacedDataItem, wa } // MeasureNow measures one item now, walk or not, and writes its reading when it changed. -func (k *Keeper) MeasureNow(ctx context.Context, module, item string) (health.Reading, bool, error) { +func (k *Keeper) MeasureNow(ctx context.Context, module, item string) (diskreadings.Reading, bool, error) { k.mu.Lock() defer k.mu.Unlock() items, err := k.Items(ctx) if err != nil { - return health.Reading{}, false, err + return diskreadings.Reading{}, false, err } for _, it := range items[module] { if it.ID != item { continue } if it.Class == ClassCache { - return health.Reading{}, false, fmt.Errorf("%s.%s is a cache, which is listed and never measured", module, item) + return diskreadings.Reading{}, false, fmt.Errorf("%s.%s is a cache, which is listed and never measured", module, item) } w, _, err := k.measureOne(ctx, it, true) if err != nil { - return health.Reading{}, false, err + return diskreadings.Reading{}, false, err } - raw, err := k.Store.Get(health.ReadingKey(k.Node, module, item)) + raw, err := k.Store.Get(diskreadings.ReadingKey(k.Node, module, item)) if err != nil { - return health.Reading{}, w, err + return diskreadings.Reading{}, w, err } - r, err := health.DecodeReading(raw) + r, err := diskreadings.DecodeReading(raw) return r, w, err } - return health.Reading{}, false, fmt.Errorf("%s declares no data item %q on this machine", module, item) + return diskreadings.Reading{}, false, fmt.Errorf("%s declares no data item %q on this machine", module, item) } // Loop runs a round after a few minutes and then every `every`, with the walks of the night's round when it is the diff --git a/modules/disk-load/cmd/disk-load/measure.go b/modules/disk-load/cmd/disk-load/measure.go index 2973080b7..078043d75 100644 --- a/modules/disk-load/cmd/disk-load/measure.go +++ b/modules/disk-load/cmd/disk-load/measure.go @@ -6,8 +6,8 @@ package main import ( "context" "fmt" + "git.novox.be/novox/mesh-catalog/modules/disk-load/diskreadings" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/health" "regexp" "strconv" "strings" @@ -56,9 +56,9 @@ func (b Measurer) exists(ctx context.Context, path string) bool { } // Measure is one item, measured now, by its own method. -func (b Measurer) Measure(ctx context.Context, it catalogue.PlacedDataItem) health.Measured { +func (b Measurer) Measure(ctx context.Context, it catalogue.PlacedDataItem) diskreadings.Measured { at := b.Now().UTC() - m := health.Measured{MeasuredAt: &at} + m := diskreadings.Measured{MeasuredAt: &at} if !b.exists(ctx, it.Path) { m.Error = it.Path + " does not exist" zero := int64(0) @@ -120,7 +120,7 @@ func (b Measurer) Measure(ctx context.Context, it catalogue.PlacedDataItem) heal // redundancyOf is the redundant storage a path is on: a ZFS pool's health, an md array's members, a btrfs // filesystem's error counters. Nil for storage with no redundancy it knows how to read. -func (b Measurer) redundancyOf(ctx context.Context, path string) *health.Redundancy { +func (b Measurer) redundancyOf(ctx context.Context, path string) *diskreadings.Redundancy { out, err := b.Run(ctx, "findmnt", "-no", "SOURCE,FSTYPE", "--target", path) if err != nil { return nil @@ -134,7 +134,7 @@ func (b Measurer) redundancyOf(ctx context.Context, path string) *health.Redunda switch { case fstype == "zfs": pool, _, _ := strings.Cut(source, "/") - r := &health.Redundancy{Kind: "zfs", Where: pool} + r := &diskreadings.Redundancy{Kind: "zfs", Where: pool} health, err := b.Run(ctx, "zpool", "list", "-H", "-o", "health", pool) if err != nil { r.Said = "zpool list: " + err.Error() @@ -156,7 +156,7 @@ func (b Measurer) redundancyOf(ctx context.Context, path string) *health.Redunda return r case strings.HasPrefix(source, "/dev/md"): device := strings.TrimPrefix(source, "/dev/") - r := &health.Redundancy{Kind: "md", Where: device} + r := &diskreadings.Redundancy{Kind: "md", Where: device} mdstat, err := b.Run(ctx, "cat", "/proc/mdstat") if err != nil { r.Said = err.Error() @@ -175,7 +175,7 @@ func (b Measurer) redundancyOf(ctx context.Context, path string) *health.Redunda } return r case fstype == "btrfs": - r := &health.Redundancy{Kind: "btrfs", Where: source} + r := &diskreadings.Redundancy{Kind: "btrfs", Where: source} stats, err := b.Run(ctx, "btrfs", "device", "stats", "--check", path) if err != nil { r.Healthy, r.Said = &no, "device errors: "+oneLineOf(err.Error()) diff --git a/modules/disk-load/cmd/disk-load/packages_test.go b/modules/disk-load/cmd/disk-load/packages_test.go new file mode 100644 index 000000000..dabc59ef9 --- /dev/null +++ b/modules/disk-load/cmd/disk-load/packages_test.go @@ -0,0 +1,38 @@ +package main + +import ( + "encoding/json" + "os" + "slices" + "testing" +) + +// The readings' layout is disk-load's own package, published by its series for the readers to take (ADR 0634 §4). +func TestTheReadingsLayoutIsAPublishedPackageOfThisModule(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Reads []string `json:"reads"` + Build struct { + Artifacts []struct { + Name, Kind, Series string + Paths []string + } `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + // The node-engine's data state is read under the one name of its holder this module keeps. + if !slices.Contains(m.Reads, engineDataRead) { + t.Errorf("reads %v do not name %s", m.Reads, engineDataRead) + } + for _, a := range m.Build.Artifacts { + if a.Name == "diskreadings" && a.Kind == "package" && a.Series == "0.1" && len(a.Paths) == 1 && a.Paths[0] == "diskreadings" { + return + } + } + t.Errorf("no package artifact diskreadings of series 0.1 in %s", raw) +} diff --git a/modules/disk-load/cmd/disk-load/readings.go b/modules/disk-load/cmd/disk-load/readings.go index c8528d309..74f28a3bb 100644 --- a/modules/disk-load/cmd/disk-load/readings.go +++ b/modules/disk-load/cmd/disk-load/readings.go @@ -1,12 +1,12 @@ package main -// The readings layout, their key and the peak are mesh-sdk's (health.Reading, health.NextReading; novox/hq ADR 0571 +// The readings layout, their key and the peak are the package diskreadings (Reading, NextReading; novox/hq ADR 0571 // clause 3): disk-load measures each placed data item of its node and keeps the readings as its own state. -import "git.novox.be/novox/mesh-sdk/go/health" +import "git.novox.be/novox/mesh-catalog/modules/disk-load/diskreadings" // StateReadings is the state disk-load keeps. -const StateReadings = health.ReadingsState +const StateReadings = diskreadings.ReadingsState // StateRounds is its state of each node's last round, key `` (novox/hq ADR 0577 clause 3): apart from the readings, // so a reader of the readings never meets a round. diff --git a/modules/disk-load/cmd/disk-load/readings_test.go b/modules/disk-load/cmd/disk-load/readings_test.go index eb1d57851..c97f48f37 100644 --- a/modules/disk-load/cmd/disk-load/readings_test.go +++ b/modules/disk-load/cmd/disk-load/readings_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-catalog/modules/disk-load/diskreadings" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/health" ) type memStore struct { @@ -84,11 +84,11 @@ func TestARoundMeasuresTheDeclaredItemsAndWritesWhatChanged(t *testing.T) { if _, _, err := k.Round(context.Background(), false); err != nil { t.Fatal(err) } - if kept, _ := health.DecodeReading(store.m["ace.pg.data"]); !kept.RetiredAt.IsZero() { + if kept, _ := diskreadings.DecodeReading(store.m["ace.pg.data"]); !kept.RetiredAt.IsZero() { t.Fatalf("a round retired a reading: %+v", kept) } k.MeasureModule(context.Background(), "pg") - gone, _ := health.DecodeReading(store.m["ace.pg.data"]) + gone, _ := diskreadings.DecodeReading(store.m["ace.pg.data"]) if gone.RetiredAt.IsZero() || !strings.Contains(gone.RetiredWhy, "no longer declared on ace") || gone.FirstSeen.IsZero() { t.Fatalf("a reading of an item no longer declared: %+v", gone) } @@ -96,7 +96,7 @@ func TestARoundMeasuresTheDeclaredItemsAndWritesWhatChanged(t *testing.T) { if _, _, err := k.Round(context.Background(), false); err != nil { t.Fatal(err) } - if back, _ := health.DecodeReading(store.m["ace.pg.data"]); !back.RetiredAt.IsZero() { + if back, _ := diskreadings.DecodeReading(store.m["ace.pg.data"]); !back.RetiredAt.IsZero() { t.Fatalf("a reading declared again is still retired: %+v", back) } if _, _, err := k.MeasureNow(context.Background(), "pg", "tmp"); err == nil { diff --git a/modules/disk-load/cmd/disk-load/readings_tools.go b/modules/disk-load/cmd/disk-load/readings_tools.go index 044e3d46d..665701143 100644 --- a/modules/disk-load/cmd/disk-load/readings_tools.go +++ b/modules/disk-load/cmd/disk-load/readings_tools.go @@ -8,8 +8,8 @@ import ( "strings" "time" + "git.novox.be/novox/mesh-catalog/modules/disk-load/diskreadings" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/health" ) // ReadingTools are the tools over the state `readings`: every key, one key, how full the state is, and one item @@ -35,7 +35,7 @@ func ReadingTools(k *Keeper) []stdio.Tool { if len(raw) == 0 { return nil, fmt.Errorf("no reading is kept at %q", key) } - r, err := health.DecodeReading(raw) + r, err := diskreadings.DecodeReading(raw) return r, err } keys, err := k.Store.Keys() @@ -45,12 +45,12 @@ func ReadingTools(k *Keeper) []stdio.Tool { sort.Strings(keys) module := text(args, "module") type line struct { - Key string `json:"key"` - SizeBytes *int64 `json:"size_bytes,omitempty"` - PeakBytes *int64 `json:"peak_bytes,omitempty"` - Precision string `json:"precision,omitempty"` - Error string `json:"error,omitempty"` - Reading health.Reading `json:"-"` + Key string `json:"key"` + SizeBytes *int64 `json:"size_bytes,omitempty"` + PeakBytes *int64 `json:"peak_bytes,omitempty"` + Precision string `json:"precision,omitempty"` + Error string `json:"error,omitempty"` + Reading diskreadings.Reading `json:"-"` } var lines []line total := 0 @@ -60,7 +60,7 @@ func ReadingTools(k *Keeper) []stdio.Tool { return nil, err } total += len(raw) - r, err := health.DecodeReading(raw) + r, err := diskreadings.DecodeReading(raw) if err != nil || (module != "" && r.Module != module) { continue } @@ -118,7 +118,7 @@ func ReadingTools(k *Keeper) []stdio.Tool { if err != nil { return nil, err } - return map[string]any{"written": written, "reading": r, "key": health.ReadingKey(k.Node, module, item), + return map[string]any{"written": written, "reading": r, "key": diskreadings.ReadingKey(k.Node, module, item), "note": strings.TrimSpace(map[bool]string{true: "the reading changed and was written", false: "the reading had not changed"}[written])}, nil }, }, diff --git a/modules/disk-load/cmd/disk-load/whole_integration_test.go b/modules/disk-load/cmd/disk-load/whole_integration_test.go index 2cff6f383..fa33221f6 100644 --- a/modules/disk-load/cmd/disk-load/whole_integration_test.go +++ b/modules/disk-load/cmd/disk-load/whole_integration_test.go @@ -26,9 +26,10 @@ import ( "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" + "git.novox.be/novox/mesh-catalog/modules/disk-load/diskreadings" + engineclient "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/health" "git.novox.be/novox/mesh-sdk/go/testbus" ) @@ -195,7 +196,7 @@ func TestDiskLoadWholeMeasuresAnItemWithinAMinuteOfItsKey(t *testing.T) { if err != nil { t.Fatal(err) } - for _, b := range []string{delivery.EngineDataBucket, health.ReadingsBucket, "disk-load_" + StateRounds} { + for _, b := range []string{engineDataBucket, diskreadings.ReadingsBucket, "disk-load_" + StateRounds} { if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: b}); err != nil { t.Fatal(err) } @@ -217,7 +218,7 @@ func TestDiskLoadWholeMeasuresAnItemWithinAMinuteOfItsKey(t *testing.T) { p.mu.Lock() defer p.mu.Unlock() for _, a := range p.asked { - if a == "mesh/state.watch "+delivery.EngineDataRead+" ace.*" { + if a == "mesh/state.watch "+engineDataRead+" ace.*" { return true } } @@ -225,26 +226,26 @@ func TestDiskLoadWholeMeasuresAnItemWithinAMinuteOfItsKey(t *testing.T) { } for end := time.Now().Add(20 * time.Second); !watching(); time.Sleep(50 * time.Millisecond) { if time.Now().After(end) { - t.Fatalf("disk-load did not watch its machine's keys of %s: asked %v", delivery.EngineDataBucket, p.asked) + t.Fatalf("disk-load did not watch its machine's keys of %s: asked %v", engineDataBucket, p.asked) } } - data, _ := js.KeyValue(ctx, delivery.EngineDataBucket) + data, _ := js.KeyValue(ctx, engineDataBucket) dir := t.TempDir() item := catalogue.PlacedDataItem{Module: "pg", ID: "data", Class: "valuable", Path: dir, Measure: catalogue.MeasureShallow} - raw, err := delivery.EncodeEngineData(delivery.EngineData{Node: "ace", Module: "pg", Digest: "sha256:pg", Items: []catalogue.PlacedDataItem{item}}) + raw, err := engineclient.EncodeData(nodeengine.DataByNodeModuleValue{V: 1, Node: "ace", Module: "pg", Digest: "sha256:pg", Items: seatItems([]catalogue.PlacedDataItem{item})}) if err != nil { t.Fatal(err) } - key, _ := delivery.EngineDataKey("ace", "pg") + key, _ := engineclient.DataKey("ace", "pg") written := time.Now() if _, err := data.Put(ctx, key, raw); err != nil { t.Fatal(err) } - readings, _ := js.KeyValue(ctx, health.ReadingsBucket) + readings, _ := js.KeyValue(ctx, diskreadings.ReadingsBucket) for { - e, err := readings.Get(ctx, health.ReadingKey("ace", "pg", "data")) + e, err := readings.Get(ctx, diskreadings.ReadingKey("ace", "pg", "data")) if err == nil { - r, err := health.DecodeReading(e.Value()) + r, err := diskreadings.DecodeReading(e.Value()) if err != nil || r.Path != dir || r.MeasuredAt == nil { t.Fatalf("the reading is %s (%v)", e.Value(), err) } @@ -260,7 +261,7 @@ func TestDiskLoadWholeMeasuresAnItemWithinAMinuteOfItsKey(t *testing.T) { defer p.mu.Unlock() for _, a := range p.asked { f := strings.Fields(a) - if len(f) > 1 && strings.HasPrefix(f[0], "mesh/state.") && f[1] != delivery.EngineDataRead && f[1] != StateReadings && f[1] != StateRounds { + if len(f) > 1 && strings.HasPrefix(f[0], "mesh/state.") && f[1] != engineDataRead && f[1] != StateReadings && f[1] != StateRounds { t.Errorf("disk-load read %s, which is no state of its own nor the engine's data", a) } } diff --git a/modules/disk-load/diskreadings/go.mod b/modules/disk-load/diskreadings/go.mod new file mode 100644 index 000000000..bdecc1fdb --- /dev/null +++ b/modules/disk-load/diskreadings/go.mod @@ -0,0 +1,5 @@ +module git.novox.be/novox/mesh-catalog/modules/disk-load/diskreadings + +go 1.26.0 + +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 diff --git a/modules/disk-load/diskreadings/readings.go b/modules/disk-load/diskreadings/readings.go new file mode 100644 index 000000000..ce4ef1322 --- /dev/null +++ b/modules/disk-load/diskreadings/readings.go @@ -0,0 +1,137 @@ +package diskreadings + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// **Disk usage readings and peaks per data item** (novox/hq ADR 0571 clause 3, ADR 0233 §21, §27, §34): disk-load, on every +// node, measures each data item the modules of its node declare (catalogue.PlacedDataItem, from compose/frombus.DataItems) +// and keeps the newest reading and the item's largest size in seven days as its own state `readings`, one key +// `..`, written only when it changes. The node-backup holder, mounts, nfs-server and the self-check's +// probes read it there; the controller keeps none of it. The package is disk-load's own, published by its series (ADR 0634 §4); the readers import it. +const ( + DiskLoadModule = "disk-load" + ReadingsState = "readings" + ReadingsBucket = DiskLoadModule + "_" + ReadingsState + ReadingsRead = DiskLoadModule + "." + ReadingsState +) + +// ReadingVersion is the version of a reading's layout. +const ReadingVersion = 1 + +// PeakWindow is how far back the peak looks (ADR 0233 §34). +const PeakWindow = 7 * 24 * time.Hour + +// Redundancy is the redundant storage an item is on, as read: zfs, md or btrfs, its pool, array or filesystem, and its +// health (nil when it could not be read). +type Redundancy struct { + Kind string `json:"kind"` + Where string `json:"where"` + Healthy *bool `json:"healthy"` + Said string `json:"said"` +} + +// Measured is what one measurement found. Precision says what the size is: "exact", "dataset : its whole size", +// "partial: ..." (a lower bound, never compared), or "no size: ...". +type Measured struct { + SizeBytes *int64 `json:"size_bytes,omitempty"` + LastWrite *time.Time `json:"last_write,omitempty"` + MeasuredAt *time.Time `json:"measured_at,omitempty"` + Precision string `json:"precision,omitempty"` + Error string `json:"error,omitempty"` + Redundancy *Redundancy `json:"redundancy,omitempty"` +} + +// Partial says the size is only a lower bound, which a peak is never made of. +func (m Measured) Partial() bool { return strings.HasPrefix(m.Precision, "partial") } + +// DaySize is the largest size measured on one UTC day. +type DaySize struct { + Day string `json:"day"` + Bytes int64 `json:"bytes"` +} + +// Reading is the value of one key: the item as placed, what was measured, and the largest size in the seven days up to +// it (Peak, measured on PeakAt), read from the day maxima Days. +type Reading struct { + V int `json:"v"` + Node string `json:"node"` + catalogue.PlacedDataItem + Measured + Peak *int64 `json:"peak_bytes,omitempty"` + PeakAt string `json:"peak_day,omitempty"` + Days []DaySize `json:"days,omitempty"` + // FirstSeen is when the item was first measured on this node, kept by every reading after; RetiredAt and RetiredWhy + // say its module left the node, written by whoever retires it and carried on unchanged. Each is left out while zero, + // so a reading written before reads as it did. + FirstSeen time.Time `json:"first_seen,omitzero"` + RetiredAt time.Time `json:"retired_at,omitzero"` + RetiredWhy string `json:"retired_why,omitempty"` +} + +// ReadingKey is the key of an item on a node: `..`. +func ReadingKey(node, module, item string) string { return node + "." + module + "." + item } + +// DecodeReading reads a key's value; it refuses another version. +func DecodeReading(raw []byte) (Reading, error) { + var r Reading + if err := json.Unmarshal(raw, &r); err != nil { + return Reading{}, fmt.Errorf("diskreadings: a reading is not readable: %w", err) + } + if r.V != ReadingVersion { + return Reading{}, fmt.Errorf("diskreadings: a reading of version %d, and this reads %d", r.V, ReadingVersion) + } + return r, nil +} + +// NextReading is the reading after a measurement, and whether it differs from the one before in anything but the time it +// was taken: a reading that did not change is not written. A measurement with no size (an error, a shallow item) carries +// the last size's days on, so the peak outlives one failed measurement. +func NextReading(prev *Reading, node string, it catalogue.PlacedDataItem, m Measured, now time.Time) (Reading, bool) { + r := Reading{V: ReadingVersion, Node: node, PlacedDataItem: it, Measured: m, FirstSeen: now.UTC()} + var days []DaySize + if prev != nil { + days = prev.Days + r.RetiredAt, r.RetiredWhy = prev.RetiredAt, prev.RetiredWhy + if !prev.FirstSeen.IsZero() { + r.FirstSeen = prev.FirstSeen + } + } + today := now.UTC().Format("2006-01-02") + cut := now.UTC().Add(-PeakWindow).Format("2006-01-02") + var kept []DaySize + todays := int64(-1) + for _, d := range days { + switch { + case d.Day == today: + todays = d.Bytes + case d.Day >= cut: + kept = append(kept, d) + } + } + if m.SizeBytes != nil && !m.Partial() && *m.SizeBytes > todays { + todays = *m.SizeBytes + } + if todays >= 0 { + kept = append(kept, DaySize{Day: today, Bytes: todays}) + } + r.Days = kept + for _, d := range kept { + if r.Peak == nil || d.Bytes >= *r.Peak { + b := d.Bytes + r.Peak, r.PeakAt = &b, d.Day + } + } + if prev == nil { + return r, true + } + a, b := *prev, r + a.MeasuredAt, b.MeasuredAt = nil, nil + return r, !reflect.DeepEqual(a, b) +} diff --git a/modules/disk-load/diskreadings/readings_test.go b/modules/disk-load/diskreadings/readings_test.go new file mode 100644 index 000000000..1d4c51996 --- /dev/null +++ b/modules/disk-load/diskreadings/readings_test.go @@ -0,0 +1,55 @@ +package diskreadings + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// ADR 0571 clause 3: a reading keeps its seven-day peak and is written only when it changes; a partial size is no peak. +func TestAReadingKeepsItsPeakAndIsWrittenOnlyWhenItChanges(t *testing.T) { + at := time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC) + var prev *Reading + for i, step := range []struct { + day, size int64 + partial, wrote bool + peak int64 + }{{20, 100, false, true, 100}, {20, 100, false, false, 100}, {21, 300, false, true, 300}, {22, 50, false, true, 300}, + {23, 9000, true, true, 300}, {29, 10, false, true, 50}, {30, 10, false, true, 10}} { + now := time.Date(2026, 10, int(step.day), 1, 0, 0, 0, time.UTC) + size := step.size + m := Measured{SizeBytes: &size, MeasuredAt: &now, Precision: "exact"} + if step.partial { + m.Precision = "partial: stopped" + } + r, wrote := NextReading(prev, "ace", catalogue.PlacedDataItem{Module: "pg", ID: "data", Path: "/srv/pg"}, m, now) + raw, _ := json.Marshal(r) + back, err := DecodeReading(raw) + if wrote != step.wrote || r.Peak == nil || *r.Peak != step.peak || err != nil || back.ID != "data" { + t.Fatalf("reading %d: wrote %v, peak %v, read back %v", i, wrote, r.Peak, err) + } + prev = &r + } + // The first reading sets FirstSeen and every one after keeps it, as it keeps a retirement; an older reading reads as before. + if first := time.Date(2026, 10, 20, 1, 0, 0, 0, time.UTC); !prev.FirstSeen.Equal(first) { + t.Fatalf("first seen %s, not %s", prev.FirstSeen, first) + } + retired := *prev + retired.RetiredAt, retired.RetiredWhy = at, "its module left ace" + if r, _ := NextReading(&retired, "ace", retired.PlacedDataItem, retired.Measured, at.Add(time.Hour)); !r.RetiredAt.Equal(at) || + r.RetiredWhy != "its module left ace" || !r.FirstSeen.Equal(prev.FirstSeen) { + t.Fatalf("a retired reading moved on as %+v", r) + } + if old, err := DecodeReading([]byte(`{"v":1,"node":"ace","module":"pg","item":"data"}`)); err != nil || !old.FirstSeen.IsZero() { + t.Fatalf("a reading of before read as %+v: %v", old, err) + } + if raw, _ := json.Marshal(Reading{V: ReadingVersion}); strings.Contains(string(raw), "first_seen") || strings.Contains(string(raw), "retired") { + t.Fatalf("zero fields were written: %s", raw) + } + if ReadingKey("ace", "pg", "data") != "ace.pg.data" || ReadingsBucket != "disk-load_readings" { + t.Fatal("the readings' key or bucket moved") + } +} diff --git a/modules/disk-load/go.mod b/modules/disk-load/go.mod index 7d8d3f33c..508873337 100644 --- a/modules/disk-load/go.mod +++ b/modules/disk-load/go.mod @@ -3,20 +3,26 @@ module diskload go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 github.com/nats-io/nats.go v1.54.0 ) require ( - github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect + github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/minio/highwayhash v1.0.4 // indirect - github.com/nats-io/jwt/v2 v2.8.1 // indirect + github.com/nats-io/jwt/v2 v2.8.2 // indirect github.com/nats-io/nats-server/v2 v2.11.17 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect golang.org/x/crypto v0.57.0 // indirect golang.org/x/sys v0.48.0 // indirect - golang.org/x/time v0.15.0 // indirect + golang.org/x/time v0.16.0 // indirect ) + +require git.novox.be/novox/mesh-catalog/modules/disk-load/diskreadings v0.0.0-00010101000000-000000000000 + +replace git.novox.be/novox/mesh-catalog/modules/disk-load/diskreadings => ./diskreadings diff --git a/modules/disk-load/go.sum b/modules/disk-load/go.sum index 43cc4ec77..8ca93dfd0 100644 --- a/modules/disk-load/go.sum +++ b/modules/disk-load/go.sum @@ -1,15 +1,13 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= -github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= -github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc= +github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc= github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= @@ -23,5 +21,5 @@ golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZ golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= diff --git a/modules/disk-load/module.json b/modules/disk-load/module.json index 7b162bf90..4cbd028d5 100644 --- a/modules/disk-load/module.json +++ b/modules/disk-load/module.json @@ -24,6 +24,15 @@ "loads": [ "disk-load" ] + }, + { + "name": "diskreadings", + "kind": "package", + "language": "go", + "paths": [ + "diskreadings" + ], + "series": "0.1" } ] }, diff --git a/modules/distribution/README.md b/modules/distribution/README.md index a7a90a38c..bd073d4fe 100644 --- a/modules/distribution/README.md +++ b/modules/distribution/README.md @@ -1,6 +1,9 @@ # distribution -The `distribution` module of the mesh catalogue: runs the container image `registry`; claims the seat `mesh-artifact-store`. +The `distribution` module of the mesh catalogue: runs the container image `registry`; holds the seats `container-image-registry` +and `oci-artifact-registry`, and `mesh-artifact-store` until its window closes. It provides no provision: the store's address +is its listen `registry`, open to the mesh, which every node-engine finds through the seat's holder (novox/hq ADR 0632). Its +seats' `list` answers what the store holds, read from the store's own files through its container `mesh-registry`. ## Files it does not know diff --git a/modules/distribution/cmd/distribution-tools/contract.go b/modules/distribution/cmd/distribution-tools/contract.go new file mode 100644 index 000000000..c16ada558 --- /dev/null +++ b/modules/distribution/cmd/distribution-tools/contract.go @@ -0,0 +1,71 @@ +package main + +// The seats' contracts (mesh-contracts, generated from the seat definitions; ADR 0620): every verb's input and answer, +// and every event's body, as the two seats distribution holds define them. The verbs common to both seats have one +// shape in every registry seat, so containerimageregistry's types serve oci-artifact-registry's too. The holder's own +// types stay inside; what crosses the bus is the contract's. + +import ( + "encoding/base64" + "fmt" + "time" + + reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + + "distribution/internal/holder" +) + +func publishOf(in reg.PublishInput) holder.PublishRequest { + r := holder.PublishRequest{Transfer: in.Transfer, Name: in.Name, Version: in.Version, Tag: in.Tag, Kind: in.Kind, + Fingerprint: in.Fingerprint, Commit: in.Commit, Digest: in.Digest} + for _, p := range in.Parts { + r.Parts = append(r.Parts, holder.Part{Digest: p.Digest, Size: p.Size}) + } + return r +} + +func publishAnswer(a holder.PublishAnswer) reg.PublishAnswer { + return reg.PublishAnswer{Transfer: a.Transfer, Lacks: a.Missing, Held: a.Ended && len(a.Missing) == 0} +} + +func partOf(in reg.PublishPartInput) (holder.PartRequest, error) { + data, err := base64.StdEncoding.DecodeString(in.Data) + if err != nil { + return holder.PartRequest{}, fmt.Errorf("publish-part: data is not base64: %w", err) + } + return holder.PartRequest{Transfer: in.Transfer, Digest: in.Part, Sequence: int(in.Sequence), Last: in.Last, Data: data}, nil +} + +func partAnswer(in reg.PublishPartInput, a holder.PublishAnswer) reg.PublishPartAnswer { + return reg.PublishPartAnswer{Transfer: in.Transfer, Part: in.Part, Received: in.Sequence + 1, Complete: a.Ended} +} + +// versionsAnswer is `versions`: each version with the digest the store holds under it. +func versionsAnswer(v *holder.Verbs, kind string, a holder.VersionsAnswer) reg.VersionsAnswer { + out := reg.VersionsAnswer{Name: a.Name, Versions: []reg.Artifact{}} + for _, ver := range a.Versions { + d, _ := v.Catalog.Digest(a.Name, ver) + out.Versions = append(out.Versions, reg.Artifact{Name: a.Name, Version: ver, Digest: d, Kind: kind}) + } + return out +} + +func tagsAnswer(v *holder.Verbs, a holder.VersionsAnswer) reg.TagsAnswer { + out := reg.TagsAnswer{Name: a.Name, Tags: []reg.TagsAnswerTagsItem{}} + for _, t := range a.Versions { + d, _ := v.Catalog.Digest(a.Name, t) + out.Tags = append(out.Tags, reg.TagsAnswerTagsItem{Tag: t, Digest: d}) + } + return out +} + +// eventBody is an event's body as the seat's contract defines it: an Outcome for published, refused, removed and kept, a +// Hold for held and released. +func eventBody(event string, e holder.Event) any { + switch event { + case holder.EventHeld, holder.EventReleased: + return reg.Hold{Name: e.Name, Digest: e.Digest, Holder: e.Holder, By: e.By, Reason: e.Reason, At: time.Now().UTC().Format(time.RFC3339)} + } + return reg.Outcome{Name: e.Name, Version: e.Version, Kind: e.Kind, Tag: e.Tag, Digest: e.Digest, Fingerprint: e.Fingerprint, + Commit: e.Commit, Holder: e.Holder, Transfer: e.Transfer, Reference: e.Reference, Reason: e.Reason, Tags: e.Tags} +} diff --git a/modules/distribution/cmd/distribution-tools/contract_test.go b/modules/distribution/cmd/distribution-tools/contract_test.go new file mode 100644 index 000000000..d2453f9ea --- /dev/null +++ b/modules/distribution/cmd/distribution-tools/contract_test.go @@ -0,0 +1,88 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + + "distribution/internal/holder" +) + +func jsonFields(v any) map[string]bool { + out := map[string]bool{} + t := reflect.TypeOf(v) + for i := 0; i < t.NumField(); i++ { + name, _, _ := strings.Cut(t.Field(i).Tag.Get("json"), ",") + out[name] = true + } + return out +} + +// TestTheVerbsSpeakTheSeatsContract holds distribution's seat verbs to the contracts mesh-contracts generates from the +// two seats' definitions (ADR 0620 *How it is checked*): every argument a verb's tool takes is a field of the +// contract's input, and every event body decodes into the contract's type with no field left over. +func TestTheVerbsSpeakTheSeatsContract(t *testing.T) { + inputs := map[string]any{reg.VerbPublish: reg.PublishInput{}, reg.VerbPublishPart: reg.PublishPartInput{}, + reg.VerbVersions: reg.VersionsInput{}, reg.VerbRemove: reg.RemoveInput{}, reg.VerbHold: reg.HoldInput{}, + reg.VerbRelease: reg.ReleaseInput{}, reg.VerbMirror: reg.MirrorInput{}, reg.VerbTags: reg.TagsInput{}} + seen := 0 + for _, tool := range tools(&holder.Verbs{}, kept[holder.Window]{}, func() (holder.Callers, error) { return holder.DefaultCallers(), nil }) { + seat, verb, ok := strings.Cut(tool.Name, ".") + in, isVerb := inputs[verb] + if !ok || !isVerb || (seat != holder.ImageSeat && seat != holder.ArtifactSeat) { + continue + } + seen++ + fields := jsonFields(in) + props, _ := tool.Input["properties"].(map[string]any) + for p := range props { + if !fields[p] { + t.Errorf("%s takes %q, which the contract's input does not name", tool.Name, p) + } + } + } + if seen != 14 { + t.Errorf("%d seat verbs served, want 14 (8 on the image seat, 6 on the artifact seat)", seen) + } + for event, want := range map[string]any{holder.EventPublished: ®.Outcome{}, holder.EventKept: ®.KeptEvent{}, holder.EventHeld: ®.Hold{}, holder.EventReleased: ®.Hold{}} { + b, _ := json.Marshal(eventBody(event, holder.Event{Name: "m/a", Kind: holder.KindArchive, Digest: "sha256:aa", Holder: holder.Self, By: "x.artifact-store-tools"})) + dec := json.NewDecoder(bytes.NewReader(b)) + dec.DisallowUnknownFields() + if err := dec.Decode(want); err != nil { + t.Errorf("%s's body %s is not the contract's: %v", event, b, err) + } + } + // The caller list is read on each call: one that cannot be read answers no verb, and registry_callers says it as read. + unread := func() (holder.Callers, error) { return nil, errors.New("config refused") } + for _, tool := range tools(&holder.Verbs{}, kept[holder.Window]{}, unread) { + if tool.Name == holder.ImageSeat+"."+holder.VerbVersions { + if _, err := tool.Run(map[string]any{"name": "m/a"}); err == nil || !strings.Contains(err.Error(), "caller list") { + t.Errorf("versions with an unread caller list = %v, want refused naming the caller list", err) + } + } + } + answered := false + for _, tool := range tools(&holder.Verbs{}, kept[holder.Window]{}, func() (holder.Callers, error) { return holder.Callers{holder.VerbPublish: {"mesh-build-go"}}, nil }) { + if tool.Name == holder.CallersTool { + answered = true + got, err := tool.Run(map[string]any{}) + if b, _ := json.Marshal(got); err != nil || string(b) != `{"callers":{"publish":["mesh-build-go"]}}` { + t.Errorf("%s = %s, %v", holder.CallersTool, b, err) + } + } + } + if !answered { + t.Errorf("no tool %s: the program could not read the caller list", holder.CallersTool) + } + if a := publishAnswer(holder.PublishAnswer{Transfer: "t", Missing: []string{"sha256:bb"}}); !reflect.DeepEqual(a.Lacks, []string{"sha256:bb"}) || a.Held { + t.Errorf("publish answers %+v", a) + } + if r, err := partOf(reg.PublishPartInput{Transfer: "t", Part: "sha256:bb", Data: "aGk=", Last: true}); err != nil || string(r.Data) != "hi" || r.Digest != "sha256:bb" { + t.Errorf("publish-part read %+v, %v", r, err) + } +} diff --git a/modules/distribution/cmd/distribution-tools/main.go b/modules/distribution/cmd/distribution-tools/main.go new file mode 100644 index 000000000..baf4b2b3c --- /dev/null +++ b/modules/distribution/cmd/distribution-tools/main.go @@ -0,0 +1,548 @@ +// Command distribution-tools serves distribution's seat verbs and tools (novox/hq design 51): the verbs of +// container-image-registry and oci-artifact-registry, each as `.`, and registry_incoming, registry_holds, +// registry_old_name and registry_direct_writes. The runtime launches it on distribution's own account; the caller of +// every verb is the one the tool runner proved (stdio.CallerOf), never a name in the arguments. stdout is the MCP +// channel; what it says, it says on stderr. +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "net/url" + "os" + "strings" + "sync" + "time" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" + meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + "git.novox.be/novox/mesh-delivery/sdk/parts" + stdio "git.novox.be/novox/mesh-sdk/go" + + "distribution/internal/holder" +) + +func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, "[distribution-tools] "+format+"\n", a...) } + +// kept is one state of distribution's, through the runtime. +type kept[T any] struct { + state string + key func(T) string +} + +func (k kept[T]) Get(key string) (T, bool, error) { + var v T + e, err := stdio.State(k.state).Get(key) + if err != nil || e == nil { + return v, false, err + } + return v, true, json.Unmarshal(e.Value, &v) +} +func (k kept[T]) Put(v T) error { + _, err := stdio.State(k.state).Put(k.key(v), v) + return err +} +func (k kept[T]) Delete(key string) error { return stdio.State(k.state).Delete(key) } +func (k kept[T]) List() ([]T, error) { + keys, err := stdio.State(k.state).Keys() + if err != nil { + return nil, err + } + var out []T + for _, key := range keys { + if v, ok, err := k.Get(key); err == nil && ok { + out = append(out, v) + } + } + return out, nil +} + +func say(seat, event string, body holder.Event) error { + return stdio.SeatPublish("mesh.seat."+seat+".event."+event, eventBody(event, body), body.ID) +} + +func finding(kind, subject, words string, raised bool) { + key := meshconditionsclient.Key(meshconditionsclient.ScopeSeat, "distribution", kind+"."+strings.NewReplacer(".", "_", "@", "_", "/", "_").Replace(subject)) + if !raised { + _ = stdio.State(meshconditions.StateFindings).Delete(key) + return + } + f := meshconditions.FindingsByKeyValue{V: meshconditionsclient.FindingVersion, Key: key, Kind: kind, Ref: key[strings.LastIndex(key, ".")+1:], + Subject: meshconditions.Subject{Scope: meshconditionsclient.ScopeSeat, ID: "distribution"}, Severity: meshconditionsclient.Warning, + Summary: words, Headline: words, Explanation: words, Resolved: "cleared once it no longer holds", + Source: kind, Observer: "distribution", At: time.Now().UTC()} + if _, err := stdio.State(meshconditions.StateFindings).Put(key, f); err != nil { + logf("the finding %s could not be written: %v", key, err) + } +} + +// learned are the proved callers whose build this process recorded as publishing through the seats. +var learned sync.Map + +// learnPublishing records the fingerprint the proved caller's node runs of its build module as one that publishes +// through the seats (holder.PublishingKey), once per caller and process: the windows' driver in the program counts a +// node's have running it as publishing, closing the direct-write window when every node's build modules do. +func learnPublishing(windows kept[holder.Window], caller string) { + node, module, ok := strings.Cut(caller, ".") + if !ok || strings.HasPrefix(caller, "person.") { + return + } + if _, done := learned.Load(caller); done { + return + } + raw, err := stdio.Ask("mesh-delivery-view.node_haves", map[string]any{"node": node, "module": module}) + if err != nil { + logf("the build of %s that published cannot be read (%v): the direct-write window counts it at its next publish", caller, err) + return + } + var a struct { + Keys map[string]struct { + FP string `json:"fp"` + } `json:"keys"` + } + if json.Unmarshal(raw, &a) != nil || a.Keys[caller].FP == "" { + logf("node_haves names no build of %s: the direct-write window counts it at its next publish", caller) + return + } + learnt, err := learnInto(stdio.State("windows"), module, a.Keys[caller].FP, learnAttempts) + if err != nil { + logf("the publishing build of %s is not kept: %v", caller, err) + return + } + if learnt { + logf("%s's build %s publishes through the seats", module, a.Keys[caller].FP) + } + learned.Store(caller, true) +} + +// learnAttempts bounds learnInto's tries against other writers of the key. +const learnAttempts = 8 + +// casState is the part of a kept state learnInto writes through: a read with its revision, a create, an update at a +// revision (stdio.KeptState). +type casState interface { + Get(key string) (*stdio.StateEntry, error) + Create(key string, value any) (uint64, error) + Update(key string, value any, revision uint64) (uint64, error) +} + +// learnInto adds fp for module to the publishing builds kept at holder.PublishingKey with compare-and-set on the key's +// revision: two first publishes at once each read, add and write only if nobody wrote meanwhile, else read again, so +// neither is lost. It answers whether fp was new. +func learnInto(state casState, module, fp string, attempts int) (bool, error) { + for i := 0; i < attempts; i++ { + e, err := state.Get(holder.PublishingKey) + if err != nil { + return false, err + } + var cur holder.Publishing + if e != nil { + if err := json.Unmarshal(e.Value, &cur); err != nil { + return false, fmt.Errorf("%s holds no publishing builds: %w", holder.PublishingKey, err) + } + } + cur.Name = holder.PublishingKey + if !cur.Learn(module, fp) { + return false, nil + } + if e == nil { + _, err = state.Create(holder.PublishingKey, cur) + } else { + _, err = state.Update(holder.PublishingKey, cur, e.Revision) + } + if err == nil { + return true, nil + } + if !errors.Is(err, stdio.ErrChanged) { + return false, err + } + } + return false, fmt.Errorf("%s was written by another writer first %d times: %w", holder.PublishingKey, attempts, stdio.ErrChanged) +} + +// recordAct records a hand act through the seat mesh-hand-acts alone. +func recordAct(q meshhandactsclient.Request) error { + _, err := meshhandactsclient.Recorder{Ask: stdio.Ask}.Record(q) + return err +} + +// mirrors is the state mirrors: an ask is created once; one already kept is the same copy, taken on already. +type mirrors struct{} + +func (mirrors) Ask(key string, r holder.MirrorRequest) error { + if _, err := stdio.State(holder.MirrorsState).Create(key, r); err != nil && !errors.Is(err, stdio.ErrChanged) { + return err + } + return nil +} +func (mirrors) Claim(key string, r holder.MirrorRequest, revision uint64) (bool, error) { + _, err := stdio.State(holder.MirrorsState).Update(key, r, revision) + if errors.Is(err, stdio.ErrChanged) { + return false, nil + } + return err == nil, err +} +func (mirrors) Delete(key string) error { return stdio.State(holder.MirrorsState).Delete(key) } +func (mirrors) List() (map[string]holder.MirrorRequest, error) { + keys, err := stdio.State(holder.MirrorsState).Keys() + if err != nil { + return nil, err + } + out := map[string]holder.MirrorRequest{} + for _, k := range keys { + e, err := stdio.State(holder.MirrorsState).Get(k) + if err != nil { + return nil, err + } + var r holder.MirrorRequest + if e != nil && json.Unmarshal(e.Value, &r) == nil { + out[k] = r + } + } + return out, nil +} +func (mirrors) Put(key string, r holder.MirrorRequest) error { + _, err := stdio.State(holder.MirrorsState).Put(key, r) + return err +} + +// followMirrors runs every copy `mirror` took on, on the state's watch: the asks kept when the bundle starts (a copy a +// restart cut) and each new one, one at a time. Each is claimed first (Verbs.Take), so a second follower copies none +// a first one took on. +func followMirrors(v *holder.Verbs) { + id := make([]byte, 8) + _, _ = rand.Read(id) + self := "distribution-tools-" + hex.EncodeToString(id) + <-stdio.Served() + stdio.State(holder.MirrorsState).Follow("", func(ch stdio.StateChange) error { + if ch.Op != stdio.OpPut { + return nil + } + var r holder.MirrorRequest + if err := json.Unmarshal(ch.Value, &r); err != nil { + logf("the mirror ask %s is no ask (%v): dropped", ch.Key, err) + return stdio.State(holder.MirrorsState).Delete(ch.Key) + } + return v.Take(ch.Key, r, ch.Revision, ch.Current, self) + }) +} + +func arg[T any](args map[string]any) (T, error) { + var v T + clean := map[string]any{} + for k, x := range args { + if k != "_meta" { + clean[k] = x + } + } + b, _ := json.Marshal(clean) + return v, json.Unmarshal(b, &v) +} + +func str(args map[string]any, k string) string { s, _ := args[k].(string); return strings.TrimSpace(s) } + +// tools are distribution's tools over v; each call that checks a caller reads the caller list on that call (callers: +// the config value as mesh-config resolves it now), so an operator's change of it holds from the next call on. +func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder.Callers, error)) []stdio.Tool { + now := func() (*holder.Verbs, error) { + c, err := callers() + if err != nil { + return nil, fmt.Errorf("the caller list cannot be read, so no verb is answered: %w", err) + } + return v.WithCallers(c), nil + } + obj := func(props map[string]any, req ...string) map[string]any { + return map[string]any{"type": "object", "required": req, "properties": props} + } + s := func(d string) map[string]any { return map[string]any{"type": "string", "description": d} } + var out []stdio.Tool + for _, seat := range holder.Seats { + out = append(out, + stdio.Tool{Name: seat + "." + holder.VerbPublish, Description: "Open a transfer of a publish (ADR 0611): its name, version, kind, digest and parts; answers the parts the holder lacks.", + Input: obj(map[string]any{"transfer": s("the transfer's id"), "name": s("/"), "version": s("the builder's version"), "kind": s("image, bundle, archive or upstream"), "digest": s("sha256:…"), "parts": map[string]any{"type": "array"}}, "transfer", "name", "kind", "digest", "parts"), + Run: func(args map[string]any) (any, error) { + v, err := now() + if err != nil { + return nil, err + } + in, err := arg[reg.PublishInput](args) + if err != nil { + return nil, err + } + a, err := v.Publish(seat, stdio.CallerOf(args), publishOf(in)) + if err == nil { + learnPublishing(windows, stdio.CallerOf(args)) + } + return publishAnswer(a), err + }}, + stdio.Tool{Name: seat + "." + holder.VerbPublishPart, Description: "Send one chunk (at most 1 MiB) of one part of an open transfer, from the caller that opened it.", + Input: obj(map[string]any{"transfer": s("its id"), "part": s("the part's digest"), "sequence": map[string]any{"type": "integer"}, "last": map[string]any{"type": "boolean"}, "data": s("base64")}, "transfer", "part", "sequence", "last", "data"), + Run: func(args map[string]any) (any, error) { + v, err := now() + if err != nil { + return nil, err + } + in, err := arg[reg.PublishPartInput](args) + if err != nil { + return nil, err + } + r, err := partOf(in) + if err != nil { + return nil, err + } + a, err := v.PublishPart(seat, stdio.CallerOf(args), r) + return partAnswer(in, a), err + }}, + stdio.Tool{Name: seat + "." + holder.VerbVersions, Description: "The versions the seat holds of a name.", Input: obj(map[string]any{"name": s("/")}, "name"), + Run: func(args map[string]any) (any, error) { + v, err := now() + if err != nil { + return nil, err + } + a, err := v.Versions(seat, stdio.CallerOf(args), str(args, "name")) + if err != nil { + return nil, err + } + return versionsAnswer(v, str(args, "kind"), a), nil + }}, + stdio.Tool{Name: seat + "." + holder.VerbRemove, Description: "Remove a tagged version (the builder), or for retention an untagged one by its digest (the artifact sweep; an index with the platform manifests no tagged or held index names, and never a manifest such an index names); says removed.", + Input: obj(map[string]any{"name": s("name"), "version": s("a tagged version"), "tag": s("its tag"), "digest": s("for retention"), "reason": s("why")}, "name", "reason"), + Run: func(args map[string]any) (any, error) { + v, err := now() + if err != nil { + return nil, err + } + in, err := arg[reg.RemoveInput](args) + if err != nil { + return nil, err + } + r := holder.RemoveRequest{Name: in.Name, Version: in.Version, Digest: in.Digest, Reason: in.Reason} + got, err := v.Remove(seat, stdio.CallerOf(args), r) + if err != nil { + return nil, err + } + return reg.RemoveAnswer{Removed: got.Removed, Kept: got.Kept, Reason: got.Reason, Tags: got.Tags, Artifact: ®.Artifact{Name: in.Name, Version: in.Version, Tag: in.Tag, Digest: in.Digest}}, nil + }}, + stdio.Tool{Name: seat + "." + holder.VerbList, Description: "What the store holds of one repository, or of every repository the seat holds: each manifest's digest, whether it is an index and the manifests it names, the tags naming it, its size and whether it is held (ADR 0632 §3). Read from the store's own files; changes nothing.", + Input: obj(map[string]any{"name": s("/; every repository when not given")}), + Run: func(args map[string]any) (any, error) { + v, err := now() + if err != nil { + return nil, err + } + repos, err := v.List(context.Background(), seat, stdio.CallerOf(args), str(args, "name")) + if err != nil { + return nil, err + } + return reg.ListAnswer{Repositories: repos}, nil + }}, + stdio.Tool{Name: seat + "." + holder.VerbHold, Description: "Keep one untagged bundle, archive or copied base from retention (the artifact sweep alone); says held.", + Input: obj(map[string]any{"name": s("name"), "digest": s("sha256:…"), "reason": s("which of the sweep's rules keeps it")}, "name", "digest", "reason"), + Run: func(args map[string]any) (any, error) { + v, err := now() + if err != nil { + return nil, err + } + in, err := arg[reg.HoldInput](args) + if err != nil { + return nil, err + } + if err := v.HoldOne(seat, stdio.CallerOf(args), holder.HoldRequest{Name: in.Name, Digest: in.Digest, Why: in.Reason}); err != nil { + return nil, err + } + return reg.HoldAnswer{Held: true, Since: time.Now().UTC().Format(time.RFC3339)}, nil + }}, + stdio.Tool{Name: seat + "." + holder.VerbRelease, Description: "Let a hold go (the artifact sweep, or the operator's session through registry_holds); says released.", + Input: obj(map[string]any{"name": s("name"), "digest": s("sha256:…"), "reason": s("why it is let go")}, "name", "digest", "reason"), + Run: func(args map[string]any) (any, error) { + v, err := now() + if err != nil { + return nil, err + } + in, err := arg[reg.ReleaseInput](args) + if err != nil { + return nil, err + } + released, err := v.Release(seat, stdio.CallerOf(args), holder.HoldRequest{Name: in.Name, Digest: in.Digest, Why: in.Reason}) + if err != nil { + return nil, err + } + return reg.ReleaseAnswer{Released: released}, nil + }}, + ) + } + out = append(out, + stdio.Tool{Name: holder.ImageSeat + "." + holder.VerbTags, Description: "The tags of an image.", Input: obj(map[string]any{"name": s("name")}, "name"), + Run: func(args map[string]any) (any, error) { + v, err := now() + if err != nil { + return nil, err + } + a, err := v.Tags(holder.ImageSeat, stdio.CallerOf(args), str(args, "name")) + if err != nil { + return nil, err + } + return tagsAnswer(v, a), nil + }}, + stdio.Tool{Name: holder.ImageSeat + "." + holder.VerbMirror, Description: "Copy an outside image, pinned by its digest, into the store as upstream// (every platform, only the parts it lacks): answers at once with the reference the copy will have, and says published (or refused, with why) on the seat's events when the copy ends; wait on that event (mesh-build-image alone).", + Input: obj(map[string]any{"source": s("/"), "digest": s("sha256:…")}, "source", "digest"), + Run: func(args map[string]any) (any, error) { + v, err := now() + if err != nil { + return nil, err + } + in, err := arg[reg.MirrorInput](args) + if err != nil { + return nil, err + } + ref, err := v.Mirror(holder.ImageSeat, stdio.CallerOf(args), holder.MirrorRequest{Source: in.Source, Digest: in.Digest}) + if err != nil { + return nil, err + } + return reg.MirrorAnswer{Digest: in.Digest, Reference: ref}, nil + }}, + stdio.Tool{Name: holder.CallersTool, Description: "Who may call which verb of distribution's seats: the trusted config value callers as mesh-config resolves it on this machine now (design 51's list where unset). The program asks it at each wake of the windows, as it has no config of its own.", + Input: obj(map[string]any{}), + Run: func(map[string]any) (any, error) { + c, err := callers() + if err != nil { + return nil, err + } + return map[string]any{"callers": c}, nil + }}, + stdio.Tool{Name: "registry_health", Description: "distribution's health check, run by the runtime each minute: versions of each seat asked through the store's read side (the program's port 5000); fails naming the program when it does not answer.", + Input: obj(map[string]any{}), + Run: func(map[string]any) (any, error) { + front, _ := url.Parse("http://127.0.0.1:5000") + return holder.Health(&holder.OCI{Base: front}, finding) + }}, + stdio.Tool{Name: "registry_incoming", Description: "Open transfers of incoming and its fill; with drop, drops one by hand (a hand act, why required).", + Input: obj(map[string]any{"drop": s("a transfer id to drop"), "why": s("why it is dropped")}), + Run: func(args map[string]any) (any, error) { + v, err := now() + if err != nil { + return nil, err + } + idle, _ := v.DropIdle() + if id := str(args, "drop"); id != "" { + t, err := v.DropByHand(id, stdio.CallerOf(args), str(args, "why")) + if err != nil { + return nil, err + } + logf("transfer %s dropped by hand by %s: %s", id, stdio.CallerOf(args), str(args, "why")) + return map[string]any{"dropped": t.ID}, nil + } + all, err := v.Transfers.List() + used, _ := v.Objects.Used() + return map[string]any{"transfers": all, "used": used, "cap": v.CapNow(), "dropped_idle": idle}, err + }}, + stdio.Tool{Name: "registry_mirrors", Description: "The copies of outside images mirror took on and has not ended (the state mirrors): each by its key, its source, digest, who asked and the follower that claimed it (none: it waits). With again, asks one again by hand, its claim let go so a follower takes it on; with drop, drops one by hand, saying refused on the image seat's events so its waiter ends (each a hand act, why required).", + Input: obj(map[string]any{"again": s("a kept copy's key to ask again"), "drop": s("a kept copy's key to drop"), "why": s("why (a hand act)")}), + Run: func(args map[string]any) (any, error) { + by, why := stdio.CallerOf(args), str(args, "why") + switch { + case str(args, "again") != "": + if why == "" { + return nil, errors.New("asking a copy again is a hand act: say why") + } + r, err := v.MirrorAgain(str(args, "again")) + if err != nil { + return nil, err + } + logf("the copy %s asked again by hand by %s: %s", str(args, "again"), by, why) + return map[string]any{"asked_again": str(args, "again"), "copy": r}, nil + case str(args, "drop") != "": + r, err := v.MirrorDrop(str(args, "drop"), by, why) + if err != nil { + return nil, err + } + logf("the copy %s dropped by hand by %s: %s", str(args, "drop"), by, why) + return map[string]any{"dropped": str(args, "drop"), "copy": r}, nil + } + all, err := v.MirrorsNow() + return map[string]any{"mirrors": all}, err + }}, + stdio.Tool{Name: "registry_holds", Description: "What is held, since when and for whom; with release, lets one go through the seat's release as the operator's session (a hand act).", + Input: obj(map[string]any{"seat": s("the seat"), "release": s("the name to release"), "digest": s("its digest"), "why": s("why")}), + Run: func(args map[string]any) (any, error) { + v, err := now() + if err != nil { + return nil, err + } + if name := str(args, "release"); name != "" { + if str(args, "why") == "" { + return nil, errors.New("a release by hand is a hand act: say why") + } + // The caller proved on this call (the operator's session) is carried on; distribution's own never. + released, err := v.Release(str(args, "seat"), stdio.CallerOf(args), holder.HoldRequest{Name: name, Digest: str(args, "digest"), Why: str(args, "why")}) + if err == nil && !released { + return map[string]any{"released": false, "note": name + " is not held: nothing was let go"}, nil + } + return map[string]any{"released": name}, err + } + return v.HeldNow() + }}, + ) + for _, w := range []string{holder.OldNameWindow, holder.DirectWritesWindow} { + name := "registry_old_name" + if w == holder.DirectWritesWindow { + name = "registry_direct_writes" + } + out = append(out, stdio.Tool{Name: name, Description: "Whether the " + w + " window is open and the nodes holding it, since when; with drop, drops a retired node from it by hand (a hand act, why required).", + Input: obj(map[string]any{"drop": s("a retired node"), "why": s("why")}), + Run: func(args map[string]any) (any, error) { + cur, _, err := windows.Get(w) + if err != nil { + return nil, err + } + cur.Name = w + if node := str(args, "drop"); node != "" { + if cur, err = cur.Drop(node, stdio.CallerOf(args), str(args, "why"), time.Now().UTC()); err != nil { + return nil, err + } + if err := windows.Put(cur); err != nil { + return nil, err + } + } + return map[string]any{"window": cur}, nil + }}) + } + return out +} + +func main() { + base, _ := url.Parse("http://127.0.0.1:5001") + store := &holder.OCI{Base: base, Upstream: &holder.Upstream{}} + h := &holder.Holder{Objects: parts.BusObjects(holder.IncomingObjects), Builder: builds.BuilderVersions{Ask: stdio.Ask}, Store: store, Say: say, Finding: finding, + Transfers: parts.BusTransfers[holder.Opening](holder.TransfersState), Cap: parts.BusCap(holder.IncomingObjects), Record: recordAct, Idle: parts.BusArm(holder.IdleState)} + files := &holder.Files{Container: or(os.Getenv("MESH_REGISTRY_CONTAINER"), "mesh-registry"), Run: holder.ExecRunner, UID: os.Getuid()} + v := &holder.Verbs{Holder: h, Catalog: store, Holds: kept[holder.Hold]{state: "holds", key: holder.Hold.Key}, Mirrors: mirrors{}, + Files: files} + go followMirrors(v) + go parts.FollowIdle(holder.IdleState, parts.IDs(h.Transfers), h.Expired, func(id string, dropped bool, err error) { + if err != nil { + logf("%v", err) + return + } + logf("transfer %s had no part for %s: dropped with its parts", id, holder.TransferIdle) + }) + windows := kept[holder.Window]{state: "windows", key: func(w holder.Window) string { return w.Name }} + if err := stdio.Serve("", tools(v, windows, holder.ConfiguredCallers)); err != nil { + logf("%v", err) + os.Exit(1) + } +} + +func or(v, d string) string { + if strings.TrimSpace(v) == "" { + return d + } + return v +} diff --git a/modules/distribution/cmd/distribution-tools/publishing_test.go b/modules/distribution/cmd/distribution-tools/publishing_test.go new file mode 100644 index 000000000..5a2085799 --- /dev/null +++ b/modules/distribution/cmd/distribution-tools/publishing_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "encoding/json" + "reflect" + "testing" + + stdio "git.novox.be/novox/mesh-sdk/go" + + "distribution/internal/holder" +) + +// casMem is a kept state with revisions; before writes it lets another writer write first, once. +type casMem struct { + value []byte + rev uint64 + before func() +} + +func (m *casMem) Get(string) (*stdio.StateEntry, error) { + if m.value == nil { + return nil, nil + } + return &stdio.StateEntry{Value: m.value, Revision: m.rev}, nil +} +func (m *casMem) write(v any, at uint64) (uint64, error) { + if f := m.before; f != nil { + m.before = nil + f() + } + if m.rev != at { + return 0, stdio.ErrChanged + } + m.value, _ = json.Marshal(v) + m.rev++ + return m.rev, nil +} +func (m *casMem) Create(_ string, v any) (uint64, error) { return m.write(v, 0) } +func (m *casMem) Update(_ string, v any, rev uint64) (uint64, error) { return m.write(v, rev) } + +// TestTwoFirstPublishesAtOnceAreBothKept: the publishing builds are written with compare-and-set, so a writer that lost +// the race reads again and neither build is lost (A3f finding 2). +func TestTwoFirstPublishesAtOnceAreBothKept(t *testing.T) { + m := &casMem{} + m.before = func() { + // Another tools process learns mesh-build-go's build first. + m.value, _ = json.Marshal(holder.Publishing{Name: holder.PublishingKey, Modules: map[string][]string{"mesh-build-go": {"fp-go"}}}) + m.rev = 1 + } + if learnt, err := learnInto(m, "mesh-build-node", "fp-node", 3); err != nil || !learnt { + t.Fatalf("learnInto = %v, %v", learnt, err) + } + var got holder.Publishing + _ = json.Unmarshal(m.value, &got) + want := map[string][]string{"mesh-build-go": {"fp-go"}, "mesh-build-node": {"fp-node"}} + if !reflect.DeepEqual(got.Modules, want) { + t.Errorf("kept %v, want both builds %v", got.Modules, want) + } + if learnt, err := learnInto(m, "mesh-build-node", "fp-node", 3); err != nil || learnt { + t.Errorf("learning a kept build again = %v, %v, want not new and no write", learnt, err) + } + m.before = func() { m.rev++ } + if _, err := learnInto(m, "mesh-build-oci", "fp-oci", 1); err == nil { + t.Error("a write lost on every attempt answered no error") + } +} diff --git a/modules/distribution/cmd/distribution/main.go b/modules/distribution/cmd/distribution/main.go new file mode 100644 index 000000000..0de9470b9 --- /dev/null +++ b/modules/distribution/cmd/distribution/main.go @@ -0,0 +1,118 @@ +// Command distribution is distribution's program (novox/hq design 51): it serves the artifact store's port in front of +// the registry container, which listens on its node's loopback only. Reads (GET, HEAD) go through to every node's +// container runtime; a write is refused but while the direct-write window is open, never over a tag the seat holds +// published (holder.Front). The windows are distribution's state windows: +// the program moves them (windows.go) and follows the direct-write window's record on each change; it holds no timer. +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "net/url" + "os" + "os/signal" + "strings" + "sync/atomic" + "syscall" + "time" + + "git.novox.be/novox/mesh-sdk/go/process" + "github.com/nats-io/nats.go/jetstream" + + "distribution/internal/holder" +) + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if err := run(ctx, os.Getenv); err != nil { + fmt.Fprintln(os.Stderr, "distribution:", err) + os.Exit(1) + } +} + +func run(ctx context.Context, getenv func(string) string) error { + logger := log.New(os.Stderr, "distribution: ", log.LstdFlags) + store, err := url.Parse(or(getenv("MESH_REGISTRY_LOOPBACK"), "http://127.0.0.1:5001")) + if err != nil { + return err + } + oci := &holder.OCI{Base: store} + var open atomic.Bool + open.Store(true) // until the window's state says it closed: no wider than today's store + cred, err := process.ReadBrokerAccount(getenv) + if err != nil { + return err + } + conn, js, err := cred.Dial() + if err != nil { + return err + } + defer conn.Close() + // The direct-write window's record, written by the tools bundle on distribution's own account. + windows := process.OptionalBucket(ctx, js, "distribution_windows", "the direct-write window is read as open until the program starts again", logger.Printf) + if windows != nil { + go follow(ctx, windows, &open, logger) + calls, err := process.Connect(cred) + if err != nil { + return err + } + defer calls.Close() + findings := process.OptionalBucket(ctx, js, "distribution_findings", "a window held for seven days is said in the log only", logger.Printf) + go drive(ctx, js, cred, &holder.Driver{Delivery: busDelivery{calls}, State: kvWindows{windows}, Callers: busCallers{calls}.Read, + Closed: sayClosed(js, cred), Finding: findingsKV(findings, logger.Printf)}, logger) + } + front := &holder.Front{Store: store, Window: &open, Logf: logger.Printf, + Published: func(name, tag string) (bool, error) { + d, err := oci.Digest(name, tag) + return d != "", err + }} + srv := &http.Server{Addr: or(getenv("MESH_REGISTRY_LISTEN"), ":5000"), Handler: front, ReadHeaderTimeout: 30 * time.Second} + errs := make(chan error, 1) + go func() { errs <- srv.ListenAndServe() }() + logger.Printf("serving the store's port %s in front of %s", srv.Addr, store) + select { + case <-ctx.Done(): + shut, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + return srv.Shutdown(shut) + case err := <-errs: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + } +} + +// follow keeps open as the direct-write window's record says, on every change of it (a watch, no timer). +func follow(ctx context.Context, kv jetstream.KeyValue, open *atomic.Bool, logger *log.Logger) { + w, err := kv.Watch(ctx, holder.DirectWritesWindow) + if err != nil { + logger.Printf("the direct-write window cannot be watched (%v): read as open", err) + return + } + for e := range w.Updates() { + if e == nil || e.Operation() != jetstream.KeyValuePut { + continue + } + var win holder.Window + if json.Unmarshal(e.Value(), &win) != nil { + continue + } + closed := !win.Open && !win.Closed.IsZero() + if closed && open.Swap(false) { + logger.Printf("the direct-write window is closed: every write but distribution's own is refused, naming publish") + } + } +} + +func or(v, d string) string { + if strings.TrimSpace(v) == "" { + return d + } + return v +} diff --git a/modules/distribution/cmd/distribution/whole_integration_test.go b/modules/distribution/cmd/distribution/whole_integration_test.go new file mode 100644 index 000000000..c60bb57bc --- /dev/null +++ b/modules/distribution/cmd/distribution/whole_integration_test.go @@ -0,0 +1,233 @@ +//go:build integration + +package main + +// distribution's program whole against a real bus (CAT-D7; design 51 *Acceptance tests*, the holder's cases): its +// run() as the node starts it, its broker account on a nats-server of this test alone, the registry container a fake +// OCI store on the loopback, the status view's node_haves and the node-engine seat's manifest-reads answered on their +// call subjects. An away node holds the old name open; its return (manifest-reads-changed) closes it, said once on +// distribution.events, after which a direct write is refused naming publish; reads stay open; a waiter on a seat's +// `published` hears none of the windows' events; and the build modules counted are publish's callers as the tools +// bundle answers the config value, not design 51's defaults. + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + neturl "net/url" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-sdk/go/testbus" + + "distribution/internal/holder" +) + +func TestDistributionWhole(t *testing.T) { + url := testbus.URL(t) + nc, err := nats.Connect(url) + if err != nil { + t.Fatal(err) + } + defer nc.Close() + js, _ := jetstream.New(nc) + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + for _, b := range []string{"distribution_windows", "distribution_findings"} { + if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: b}); err != nil { + t.Fatal(err) + } + } + if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.*.event.>", "mesh.seat.*.event.>"}}); err != nil { + t.Fatal(err) + } + if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "DISTRIBUTION_EVENTS", Subjects: []string{"mesh.stream.distribution.event.>"}, Duplicates: time.Hour}); err != nil { + t.Fatal(err) + } + if _, err := js.CreateOrUpdateConsumer(ctx, "EVENTS", jetstream.ConsumerConfig{Durable: "novox_distribution", AckPolicy: jetstream.AckExplicitPolicy, + FilterSubjects: []string{"mesh.mod.mesh-delivery-view.event.delivered", "mesh.seat.node-engine.event.manifest-reads-changed.*"}}); err != nil { + t.Fatal(err) + } + + // The status view and the node-engines: g14 away (its node-engine answers nothing) and running the old build. + var mu sync.Mutex + g14 := false + answer := func(m *nats.Msg, result any) { + b, _ := json.Marshal(map[string]any{"result": result, "node": "novox"}) + _ = m.Respond(b) + } + if _, err := nc.Subscribe("mesh.mod.mesh-delivery-view.>", func(m *nats.Msg) { + mu.Lock() + defer mu.Unlock() + fp := "fp-old" + if g14 { + fp = "fp-new" + } + answer(m, map[string]any{"keys": map[string]any{ + "ace.mesh-build-go": map[string]any{"fp": "fp-new"}, "g14.mesh-build-go": map[string]any{"fp": fp}, + "ace.gitea": map[string]any{"fp": "x"}, "g14.gitea": map[string]any{"fp": "x"}, + // A build module the operator took off publish's list: it holds no window (the caller list as configured). + "g14.mesh-build-node": map[string]any{"fp": "fp-node-old"}, + }}) + }); err != nil { + t.Fatal(err) + } + // distribution's own tools bundle answers the config value callers as mesh-config resolved it: publish by + // mesh-build-go alone. The program reads it there, never from an environment variable nothing renders. + if _, err := nc.Subscribe("mesh.mod.distribution.>", func(m *nats.Msg) { + if !strings.Contains(m.Subject, holder.CallersTool) { + return + } + answer(m, map[string]any{"callers": map[string]any{"publish": []string{"mesh-build-go"}}}) + }); err != nil { + t.Fatal(err) + } + engine := func(node string) { + for _, subj := range []string{"mesh.seat.node-engine.call.manifest-reads." + node + ".>", "mesh.seat.node-engine.tool.manifest-reads." + node} { + if _, err := nc.Subscribe(subj, func(m *nats.Msg) { + answer(m, map[string]any{"v": 1, "manifest-reads": []string{holder.NewNamesCapability}}) + }); err != nil { + t.Fatal(err) + } + } + _ = nc.Flush() + } + engine("ace") // g14 is away: nothing answers its node-engine, so it states nothing + // A waiter on a seat's published: it must hear none of the windows' events. + var woke []string + if _, err := nc.Subscribe("mesh.seat.container-image-registry.event.published", func(m *nats.Msg) { woke = append(woke, m.Subject) }); err != nil { + t.Fatal(err) + } + pub, _ := json.Marshal(holder.Publishing{Name: holder.PublishingKey, Modules: map[string][]string{"mesh-build-go": {"fp-new"}}}) + windows, _ := js.KeyValue(ctx, "distribution_windows") + if _, err := windows.Put(ctx, holder.PublishingKey, pub); err != nil { + t.Fatal(err) + } + + // The registry container on the loopback, and the program's port. + store := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/tags/list") { + _, _ = io.WriteString(w, `{"tags":[]}`) + return + } + if r.Method == http.MethodHead { + w.WriteHeader(http.StatusNotFound) // no tag published through the seat + return + } + w.WriteHeader(http.StatusCreated) + })) + defer store.Close() + l, _ := net.Listen("tcp", "127.0.0.1:0") + listen := l.Addr().String() + l.Close() + dir := t.TempDir() + acct, _ := json.Marshal(map[string]any{"url": url, "node": "novox", "module": "distribution", "user": "novox.distribution", "password": "unused"}) + _ = os.WriteFile(filepath.Join(dir, "broker"), acct, 0o600) + env := map[string]string{"MESH_BROKER_FILE": filepath.Join(dir, "broker"), "MESH_REGISTRY_LOOPBACK": store.URL, "MESH_REGISTRY_LISTEN": listen} + runCtx, stop := context.WithCancel(ctx) + done := make(chan error, 1) + go func() { done <- run(runCtx, func(k string) string { return env[k] }) }() + defer func() { stop(); <-done }() + + window := func(name string) holder.Window { + var w holder.Window + if e, err := windows.Get(ctx, name); err == nil { + _ = json.Unmarshal(e.Value(), &w) + } + return w + } + eventually := func(what string, ok func() bool) { + t.Helper() + for end := time.Now().Add(20 * time.Second); time.Now().Before(end); time.Sleep(50 * time.Millisecond) { + if ok() { + return + } + select { + case err := <-done: + t.Fatalf("the program ended: %v", err) + default: + } + } + t.Fatalf("not within 20s: %s (old-name %+v, direct-writes %+v)", what, window(holder.OldNameWindow), window(holder.DirectWritesWindow)) + } + write := func() int { + req, _ := http.NewRequest(http.MethodPut, "http://"+listen+"/v2/m/i/manifests/0.1.9", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return 0 + } + resp.Body.Close() + return resp.StatusCode + } + eventually("the start's wake: g14 holds both windows", func() bool { + o, d := window(holder.OldNameWindow), window(holder.DirectWritesWindow) + return o.Open && fmt.Sprint(o.Holding) == "[g14]" && d.Open && fmt.Sprint(d.Holding) == "[g14]" + }) + if got := write(); got != http.StatusCreated { + t.Errorf("a direct write while the window is open = %d, want it through", got) + } + + // g14 returns with the new node-engine and the new build: its manifest-reads-changed closes both windows. + mu.Lock() + g14 = true + mu.Unlock() + engine("g14") + if _, err := js.Publish(ctx, "mesh.seat.node-engine.event.manifest-reads-changed.g14", []byte(`{"v":1,"node":"g14","why":"returned"}`)); err != nil { + t.Fatal(err) + } + eventually("g14's return closes both windows", func() bool { + return !window(holder.OldNameWindow).Closed.IsZero() && !window(holder.DirectWritesWindow).Closed.IsZero() + }) + // A delivered after the close says nothing again. + if _, err := js.Publish(ctx, "mesh.mod.mesh-delivery-view.event.delivered", []byte(`{}`)); err != nil { + t.Fatal(err) + } + eventually("a direct write refused naming publish once closed", func() bool { return write() == http.StatusForbidden }) + resp, err := http.Get("http://" + listen + "/v2/m/i/tags/list") + if err != nil || resp.StatusCode != http.StatusOK { + t.Errorf("a read once the window closed: %v %v", resp, err) + } + if _, err := holder.Health(&holder.OCI{Base: mustURL("http://" + listen)}, nil); err != nil { + t.Errorf("the health check through the program's port: %v", err) + } + s, _ := js.Stream(ctx, "DISTRIBUTION_EVENTS") + eventually("the program heard every waking event, the delivered after the close included", func() bool { + c, err := js.Consumer(ctx, "EVENTS", "novox_distribution") + if err != nil { + return false + } + i, err := c.Info(ctx) + return err == nil && i.NumPending == 0 && i.NumAckPending == 0 && i.Delivered.Consumer >= 2 + }) + info, _ := s.Info(ctx) + if info.State.Msgs != 2 { + t.Errorf("distribution.events holds %d events, want old-name-closed and direct-writes-closed once each", info.State.Msgs) + } + for _, ev := range []string{holder.OldNameClosedEvent, holder.DirectWritesClosedEvent} { + if _, err := s.GetLastMsgForSubject(ctx, "mesh.stream.distribution.event."+ev); err != nil { + t.Errorf("%s not said: %v", ev, err) + } + } + if len(woke) != 0 { + t.Errorf("a waiter on published woke on %v", woke) + } +} + +func mustURL(s string) *neturl.URL { + u, err := neturl.Parse(s) + if err != nil { + panic(err) + } + return u +} diff --git a/modules/distribution/cmd/distribution/windows.go b/modules/distribution/cmd/distribution/windows.go new file mode 100644 index 000000000..15b166776 --- /dev/null +++ b/modules/distribution/cmd/distribution/windows.go @@ -0,0 +1,271 @@ +package main + +// The windows' driver (design 51 *The move*): at the program's start, on each `delivered` of the status view and on each +// node-engine's `manifest-reads-changed` (ADR 0618 §4) it reads every node through node_haves and the node-engine +// seat's manifest-reads, and steps the old-name and the direct-write windows in distribution's state windows. The +// program does it, not the tools bundle: it holds distribution's own account, its durable consumer of the events +// stream and its own stream for the closing events. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "strings" + "time" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + "git.novox.be/novox/mesh-sdk/go/process" + + "distribution/internal/holder" +) + +// The addresses the driver asks (manifest invokes). +const ( + havesAsk = "mesh-delivery-view.node_haves" + readsAsk = "seat:node-engine.manifest-reads" + // callersAsk is distribution's own tool answering its config value callers as resolved. + callersAsk = holder.Self + "." + holder.CallersTool +) + +// caller is a process's call of a tool or a seat verb. +type caller interface { + Call(key string, body any) (json.RawMessage, error) + CallOn(node, key string, body any) (json.RawMessage, error) +} + +// busCallers reads the caller list from distribution's own tools bundle (holder.CallersTool, manifest invokes): a +// program has no runtime that resolves its module's config values, the tools bundle has (stdio.Config), so the program +// asks it on each wake rather than read an environment variable nothing renders. +type busCallers struct{ c caller } + +func (b busCallers) Read() (holder.Callers, error) { + raw, err := b.c.Call(callersAsk, map[string]any{}) + if err != nil { + return nil, fmt.Errorf("%s: %w", callersAsk, err) + } + var a struct { + Callers json.RawMessage `json:"callers"` + } + if err := json.Unmarshal(unwrap(raw), &a); err != nil || len(a.Callers) == 0 { + return nil, fmt.Errorf("%s answered what is not a caller list: %s", callersAsk, unwrap(raw)) + } + return holder.ParseCallers(string(a.Callers)) +} + +// busDelivery answers holder.Delivery through the bus. +type busDelivery struct{ c caller } + +func (d busDelivery) Haves() ([]holder.NodeHave, error) { + raw, err := d.c.Call(havesAsk, map[string]any{}) + if err != nil { + return nil, err + } + var a struct { + Keys map[string]struct { + FP string `json:"fp"` + } `json:"keys"` + Cut bool `json:"cut"` + } + if err := json.Unmarshal(unwrap(raw), &a); err != nil { + return nil, fmt.Errorf("node_haves answered what is not its listing: %w", err) + } + if a.Cut { + return nil, errors.New("node_haves cut its answer, so not every node is read") + } + out := make([]holder.NodeHave, 0, len(a.Keys)) + for key, h := range a.Keys { + node, module, ok := strings.Cut(key, ".") + if ok { + out = append(out, holder.NodeHave{Node: node, Module: module, FP: h.FP}) + } + } + return out, nil +} + +func (d busDelivery) Reads(node string) (bool, []string, error) { + raw, err := d.c.CallOn(node, readsAsk, map[string]any{}) + if err != nil { + if notServed(err) { + // A node-engine not serving manifest-reads states nothing (design 51); what the call answered (an away node, + // an older node-engine, a timeout) goes with it, so the window's tool says why the node holds it. + return false, nil, err + } + // Any other failure is a read that did not happen: served true with its error, so the wake judges nothing. + return true, nil, err + } + var a struct { + Reads []string `json:"manifest-reads"` + } + if err := json.Unmarshal(unwrap(raw), &a); err != nil { + return true, nil, fmt.Errorf("%s's manifest-reads answered what is not its statement: %w", node, err) + } + return true, a.Reads, nil +} + +// notServed is whether a call's error says nothing serves the address (an away node, an older node-engine). +func notServed(err error) bool { + if errors.Is(err, nats.ErrNoResponders) || errors.Is(err, nats.ErrTimeout) || errors.Is(err, context.DeadlineExceeded) { + return true + } + m := strings.ToLower(err.Error()) + for _, w := range []string{"no responders", "serves no verb", "not served", "unknown tool", "timeout"} { + if strings.Contains(m, w) { + return true + } + } + return false +} + +// unwrap takes a tool's answer out of the MCP content form the tool runner may answer in. +func unwrap(raw json.RawMessage) json.RawMessage { + var s string + if json.Unmarshal(raw, &s) == nil { + return unwrap(json.RawMessage(s)) + } + var c struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + } + if json.Unmarshal(raw, &c) == nil && len(c.Content) > 0 { + return json.RawMessage(c.Content[0].Text) + } + return raw +} + +// kvWindows answers holder.WindowState over the state windows. +type kvWindows struct{ kv jetstream.KeyValue } + +func (k kvWindows) get(key string, v any) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + e, err := k.kv.Get(ctx, key) + if errors.Is(err, jetstream.ErrKeyNotFound) { + return nil + } + if err != nil { + return err + } + return json.Unmarshal(e.Value(), v) +} + +func (k kvWindows) Window(name string) (holder.Window, error) { + var w holder.Window + return w, k.get(name, &w) +} + +func (k kvWindows) PutWindow(w holder.Window) error { + b, _ := json.Marshal(w) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, err := k.kv.Put(ctx, w.Name, b) + return err +} + +func (k kvWindows) Publishing() (holder.Publishing, error) { + var p holder.Publishing + return p, k.get(holder.PublishingKey, &p) +} + +// findingsKV writes the driver's findings into distribution's state findings, as the tools bundle writes its own. +func findingsKV(kv jetstream.KeyValue, logf func(string, ...any)) func(kind, subject, words string, raised bool) { + return func(kind, subject, words string, raised bool) { + if kv == nil { + return + } + key := meshconditionsclient.Key(meshconditionsclient.ScopeSeat, holder.Self, kind+"."+strings.NewReplacer(".", "_", "@", "_", "/", "_").Replace(subject)) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if !raised { + _ = kv.Delete(ctx, key) + return + } + f := meshconditions.FindingsByKeyValue{V: meshconditionsclient.FindingVersion, Key: key, Kind: kind, Ref: key[strings.LastIndex(key, ".")+1:], + Subject: meshconditions.Subject{Scope: meshconditionsclient.ScopeSeat, ID: holder.Self}, Severity: meshconditionsclient.Warning, + Summary: words, Headline: words, Explanation: words + "; registry_old_name or registry_direct_writes drops a retired node by hand", + Resolved: "cleared when the window closes", Source: kind, Observer: holder.Self, At: time.Now().UTC()} + b, _ := json.Marshal(f) + if _, err := kv.Put(ctx, key, b); err != nil { + logf("the finding %s could not be written: %v", key, err) + } + } +} + +// sayClosed says a window's closing on distribution's own family distribution.events, once (its id is the window's). +func sayClosed(js jetstream.JetStream, cred process.Account) func(holder.Closed) error { + return func(c holder.Closed) error { + event := holder.OldNameClosedEvent + if c.Window == holder.DirectWritesWindow { + event = holder.DirectWritesClosedEvent + } + b, _ := json.Marshal(c) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, err := js.Publish(ctx, cred.StreamSubject(event), b, jetstream.WithMsgID(holder.Self+"."+event)) + return err + } +} + +// wakePatterns are the subject patterns of the events that wake the driver, as wakes judges them. +var wakePatterns = []string{"mesh.*.mesh-delivery-view.event.delivered", "mesh.*.node-engine.event.manifest-reads-changed.*"} + +// wakes says which events wake the driver, by subject: the status view's delivered, a node-engine's +// manifest-reads-changed (its node the subject's last token). +func wakes(subject string) (string, bool) { + switch { + case strings.HasSuffix(subject, ".mesh-delivery-view.event.delivered"): + return "delivered", true + case strings.Contains(subject, ".node-engine.event.manifest-reads-changed."): + return "manifest-reads-changed on " + subject[strings.LastIndex(subject, ".")+1:], true + } + return "", false +} + +// drive wakes the driver at start and on each waking event of the program's durable consumer of the events stream. +// A wake that could not judge is said and its event asked again a minute later. +func drive(ctx context.Context, js jetstream.JetStream, cred process.Account, d *holder.Driver, logger *log.Logger) { + wake := func(why string) error { + said, err := d.Wake(why) + for _, s := range said { + logger.Printf("%s", s) + } + if err != nil { + logger.Printf("the windows were not judged (%s): %v", why, err) + } + return err + } + _ = wake("distribution started") + // The module's one durable on the events stream is pulled by the events hub (mesh-sdk process), each waking event + // registered by its subject pattern: the seat of the view says delivered, the node-engine says a manifest read changed. + hub := process.NewEventHub(logger.Printf) + handle := func(m jetstream.Msg) { + why, ok := wakes(m.Subject()) + if !ok { + _ = m.Ack() + return + } + if wake(why) != nil { + _ = m.NakWithDelay(time.Minute) + return + } + _ = m.Ack() + } + for _, pattern := range wakePatterns { + if err := hub.Handle(pattern, handle); err != nil { + logger.Printf("the events cannot be heard (%v): the windows move only at the next start", err) + return + } + } + if err := hub.Start(ctx, js, cred); err != nil { + logger.Printf("the events stream cannot be heard (%v): the windows move only at the next start", err) + return + } + <-ctx.Done() + hub.Stop() +} diff --git a/modules/distribution/go.mod b/modules/distribution/go.mod new file mode 100644 index 000000000..4a037fc1a --- /dev/null +++ b/modules/distribution/go.mod @@ -0,0 +1,36 @@ +module distribution + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 + github.com/nats-io/nats.go v1.54.0 +) + +require ( + github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/klauspost/compress v1.20.1 // indirect + github.com/minio/highwayhash v1.0.4 // indirect + github.com/nats-io/jwt/v2 v2.8.2 // indirect + github.com/nats-io/nats-server/v2 v2.11.17 // indirect + github.com/nats-io/nkeys v0.4.16 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/time v0.16.0 // indirect +) + +require ( + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000 // indirect +) diff --git a/modules/distribution/go.sum b/modules/distribution/go.sum new file mode 100644 index 000000000..8ca93dfd0 --- /dev/null +++ b/modules/distribution/go.sum @@ -0,0 +1,25 @@ +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= +github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc= +github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc= +github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= +github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= +github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= diff --git a/modules/distribution/internal/holder/callers.go b/modules/distribution/internal/holder/callers.go new file mode 100644 index 000000000..b00894c82 --- /dev/null +++ b/modules/distribution/internal/holder/callers.go @@ -0,0 +1,131 @@ +// Package holder is distribution's holding of the two image seats, `container-image-registry` and +// `oci-artifact-registry`, and of the old seat `mesh-artifact-store` until its window closes (novox/hq design 51 *The +// four registry seats*, ADR 0605, ADR 0611, ADR 0617 §2). A build module publishes into a seat through `publish` and +// `publish-part`: the parts land in distribution's own object store `incoming`, are hashed, checked against the +// builder's `builder_versions` and stored in the artifact store, which listens on its node's loopback; the store's port +// is the program's, reads open, writes from its own process alone (front.go). +package holder + +import ( + "fmt" + "slices" + "sort" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// The seats distribution holds, and the old one it holds until its window closes. +const ( + ImageSeat = "container-image-registry" + ArtifactSeat = "oci-artifact-registry" + OldSeat = "mesh-artifact-store" +) + +// The verbs of the seats (design 26 *A seat's definition*; design 51 *One contract*). +const ( + VerbPublish = "publish" + VerbPublishPart = "publish-part" + VerbVersions = "versions" + VerbRemove = "remove" + VerbMirror = "mirror" + VerbTags = "tags" + VerbHold = "hold" + VerbRelease = "release" +) + +// Seats are the seats distribution answers verbs on. +var Seats = []string{ImageSeat, ArtifactSeat} + +// VerbsOf are a seat's verbs: mirror and tags on the image seat alone; hold, release and list on both of distribution's. +func VerbsOf(seat string) []string { + v := []string{VerbPublish, VerbPublishPart, VerbVersions, VerbRemove, VerbHold, VerbRelease, VerbList} + if seat == ImageSeat { + v = append(v, VerbMirror, VerbTags) + } + sort.Strings(v) + return v +} + +// Callers is the holder's trusted config value `callers`: per verb, the callers that may call it, read as mesh-sdk's +// seat-neutral list per verb reads it (ADR 0627 §2). The holder checks the caller the tool runner proved and stamped on +// the request (design 25 § 12.4), never a name in the request's body. +type Callers = stdio.VerbCallers + +// DefaultCallers is the list design 51 *Who may call which verb* names: publish and publish-part the build modules; +// mirror mesh-build-image; remove the builder (a tagged version) and the artifact sweep's holder (retention); hold the +// artifact sweep's holder alone; release that holder, and the operator's session only through registry_holds (Operator +// below). versions, tags and list are read by anyone on the mesh, as the store's reads are. +func DefaultCallers() Callers { + builds := []string{"mesh-build-go", "mesh-build-image", "mesh-build-node"} + return Callers{ + VerbPublish: builds, + VerbPublishPart: builds, + VerbMirror: {"mesh-build-image"}, + VerbRemove: {"mesh-delivery-builder", SweepHolder}, + VerbHold: {SweepHolder}, + VerbRelease: {SweepHolder, Operator}, + VerbVersions: {Anyone}, + VerbTags: {Anyone}, + VerbList: {Anyone}, + } +} + +// SweepHolder is the module holding the seat `mesh-artifact-sweep`, the artifact sweep. +const SweepHolder = "artifact-store-tools" + +// The entries of a list that stand for a class, and the caller the runtime names for a call it could not prove. +const ( + Anyone = stdio.AnyCaller + Operator = stdio.AnyPerson + Unproven = stdio.Unproven +) + +// Self is distribution's own account: never on release's list, so registry_holds cannot release in its own name. +const Self = "distribution" + +// ParseCallers reads the config value; an empty value is DefaultCallers. A verb no seat has is refused, and Self on +// release's list is refused (design 51). +func ParseCallers(raw string) (Callers, error) { + c, err := stdio.ParseVerbCallers(raw, VerbsOf(ImageSeat), DefaultCallers()) + if err != nil { + return nil, err + } + return selfNotReleasing(c) +} + +// ConfiguredCallers is the config value `callers` as mesh-config resolved it for this machine, read through the tools +// runtime's stdio.Config on each call (never an environment variable: nothing renders a config value into a +// program's environment); unset is DefaultCallers, and Self on release's list is refused as ParseCallers refuses it. +func ConfiguredCallers() (Callers, error) { + c, err := stdio.ConfiguredVerbCallers(CallersConfig, VerbsOf(ImageSeat), DefaultCallers()) + if err != nil { + return nil, err + } + return selfNotReleasing(c) +} + +// CallersConfig is the config value's key, and CallersTool the tools bundle's tool answering it as resolved: the +// program, which has no tools runtime, asks that tool over the bus (manifest invokes). +const ( + CallersConfig = "callers" + CallersTool = "registry_callers" +) + +// WithCallers is v with the caller list c: a copy for one call, so a list read on that call never changes another's. +func (v *Verbs) WithCallers(c Callers) *Verbs { + h := *v.Holder + h.Callers = c + out := *v + out.Holder = &h + return &out +} + +func selfNotReleasing(c Callers) (Callers, error) { + if slices.Contains(c[VerbRelease], Self) { + return nil, fmt.Errorf("callers puts %s on release's list: registry_holds would release in its own name", Self) + } + return c, nil +} + +// CallerModule is the module of a proved module caller, which the runtime stamps `.`. +func CallerModule(proved string) string { return stdio.ModuleOf(proved) } diff --git a/modules/distribution/internal/holder/driver.go b/modules/distribution/internal/holder/driver.go new file mode 100644 index 000000000..49cab5bf3 --- /dev/null +++ b/modules/distribution/internal/holder/driver.go @@ -0,0 +1,216 @@ +package holder + +import ( + "errors" + "fmt" + "maps" + "slices" + "sort" + "strings" + "time" +) + +// PublishingKey is the key of distribution's state windows that records, per build module, the fingerprints of the +// builds heard publishing through the seats: the tools bundle adds the have's fingerprint of the proved caller of each +// accepted publish. A node's have of a build module running one of them counts as publishing through the seats, so a +// node whose build module runs the same build but builds nothing (a paused build agent) does not hold the window. +const PublishingKey = "publishing" + +// Publishing is that record: module -> fingerprints. +type Publishing struct { + Name string `json:"name"` + Modules map[string][]string `json:"modules"` +} + +// Learn adds fp for module; it answers whether it was new. +func (p *Publishing) Learn(module, fp string) bool { + if module == "" || fp == "" { + return false + } + if p.Modules == nil { + p.Modules = map[string][]string{} + } + if slices.Contains(p.Modules[module], fp) { + return false + } + p.Modules[module] = append(p.Modules[module], fp) + sort.Strings(p.Modules[module]) + return true +} + +// NodeHave is one key of the status view's node_haves: a node, a module, the fingerprint it runs. +type NodeHave struct { + Node, Module, FP string +} + +// Delivery is what the windows' driver asks of the delivery parts and the node-engine seat, through their tools and +// verbs on the bus (ADR 0596): every node's haves, away ones included, and one node-engine's stated reader +// capabilities. Reads answers served false when the node-engine does not serve `manifest-reads` (it states nothing), +// with the call's error, if any, saying why (an away node, an older node-engine, a timeout). +type Delivery interface { + Haves() ([]NodeHave, error) + Reads(node string) (served bool, stated []string, err error) +} + +// WindowState is distribution's state windows. +type WindowState interface { + Window(name string) (Window, error) + PutWindow(w Window) error + Publishing() (Publishing, error) +} + +// Driver moves the old-name and the direct-write windows: at distribution's start, on each `delivered` and on each +// node-engine's `manifest-reads-changed` (ADR 0618 §4), never on a timer. Each wake reads every node, since a window +// closes only when no node holds it. +type Driver struct { + Delivery Delivery + State WindowState + // Callers is the caller list as it holds on this wake: publish's callers are the build modules counted. + Callers func() (Callers, error) + // Closed says a window's closing on distribution.events. + Closed func(c Closed) error + Finding func(kind, subject, words string, raised bool) + Now func() time.Time +} + +// FindingWindowHeld is the finding of a node holding a window open for seven days. +const FindingWindowHeld = "window-held" + +// Read reads every node: its haves, its node-engine's stated capabilities, and whether each build module's have runs a +// build heard publishing through the seats. +func (d *Driver) Read() ([]NodeRead, error) { + haves, err := d.Delivery.Haves() + if err != nil { + return nil, fmt.Errorf("node_haves cannot be read, so no window moves: %w", err) + } + pub, err := d.State.Publishing() + if err != nil { + return nil, fmt.Errorf("the publishing builds cannot be read, so no window moves: %w", err) + } + callers, err := d.Callers() + if err != nil { + return nil, fmt.Errorf("the caller list cannot be read, so no window moves: %w", err) + } + builds := callers[VerbPublish] + byNode := map[string]*NodeRead{} + var order []string + for _, h := range haves { + if h.Module == "node" || h.Node == "" { + continue + } + n := byNode[h.Node] + if n == nil { + n = &NodeRead{Node: h.Node, NamesArtifact: true} + byNode[h.Node] = n + order = append(order, h.Node) + } + if slices.Contains(builds, h.Module) { + if n.BuildModules == nil { + n.BuildModules = map[string]bool{} + } + n.BuildModules[h.Module] = slices.Contains(pub.Modules[h.Module], h.FP) + } + } + sort.Strings(order) + out := make([]NodeRead, 0, len(order)) + for _, node := range order { + n := byNode[node] + served, stated, err := d.Delivery.Reads(node) + if err != nil && served { + return nil, fmt.Errorf("%s's manifest-reads cannot be read, so no window moves: %w", node, err) + } + n.Serves, n.Stated = served, stated + if !served && err != nil { + n.NotServed = err.Error() + } + out = append(out, *n) + } + return out, nil +} + +// Wake reads every node and steps both windows; it answers a line for each thing it did, and an error when nothing +// could be judged. +func (d *Driver) Wake(why string) ([]string, error) { + nodes, err := d.Read() + if err != nil { + return nil, err + } + now := d.now() + named := make([]string, 0, len(nodes)) + byNode := map[string]NodeRead{} + for _, n := range nodes { + named = append(named, n.Node) + byNode[n.Node] = n + } + var said, errs []string + for _, c := range []struct { + name, event, seat string + holding func([]NodeRead, []DroppedNode) []string + why func(NodeRead) string + }{ + {OldNameWindow, OldNameClosedEvent, OldSeat, HoldingOldName, WhyOldName}, + {DirectWritesWindow, DirectWritesClosedEvent, "", HoldingDirectWrites, WhyDirectWrites}, + } { + cur, err := d.State.Window(c.name) + if err != nil { + errs = append(errs, err.Error()) + continue + } + if cur.Name == "" { + cur = Window{Name: c.name} + cur.Open = true + } + next, closed, overdue := cur.Step(named, c.holding(nodes, cur.Dropped), now) + if next.Open && len(named) > 0 { + next.Why = map[string]string{} + for _, n := range next.Holding { + next.Why[n] = c.why(byNode[n]) + } + } + if !windowsEqual(cur, next) { + if err := d.State.PutWindow(next); err != nil { + errs = append(errs, fmt.Sprintf("the %s window is not kept: %v", c.name, err)) + continue + } + } + for _, n := range cur.Holding { + if !slices.Contains(overdue, n) && d.Finding != nil { + d.Finding(FindingWindowHeld, c.name+"."+n, "", false) + } + } + for _, n := range overdue { + if d.Finding != nil { + d.Finding(FindingWindowHeld, c.name+"."+n, fmt.Sprintf("%s has held distribution's %s window open since %s", n, c.name, next.Since[n].Format(time.RFC3339)), true) + } + } + if closed != nil { + closed.Seat = c.seat + if d.Closed != nil { + if err := d.Closed(*closed); err != nil { + errs = append(errs, fmt.Sprintf("%s was not said: %v", c.event, err)) + } + } + said = append(said, fmt.Sprintf("the %s window is closed (%s); it was held last by %v", c.name, why, closed.LastHolding)) + } else if len(named) == 0 { + said = append(said, fmt.Sprintf("the %s window is not judged (%s): the read named no node", c.name, why)) + } else if next.Open { + said = append(said, fmt.Sprintf("the %s window stays open (%s): held by %v", c.name, why, next.Holding)) + } + } + if len(errs) > 0 { + return said, errors.New(strings.Join(errs, "; ")) + } + return said, nil +} + +func (d *Driver) now() time.Time { + if d.Now != nil { + return d.Now() + } + return time.Now().UTC() +} + +func windowsEqual(a, b Window) bool { + return a.Open == b.Open && slices.Equal(a.Holding, b.Holding) && a.Closed.Equal(b.Closed) && + len(a.Since) == len(b.Since) && len(a.Dropped) == len(b.Dropped) && maps.Equal(a.Why, b.Why) +} diff --git a/modules/distribution/internal/holder/files.go b/modules/distribution/internal/holder/files.go new file mode 100644 index 000000000..ca820e180 --- /dev/null +++ b/modules/distribution/internal/holder/files.go @@ -0,0 +1,435 @@ +package holder + +// **The store's own files, read through its own container** (novox/hq ADR 0632 §3): what `list` answers. The store's +// read side lists repositories and the tags in each, but not a manifest no tag names, and since the mesh pins every +// machine by digest that is almost every manifest the store holds. The files list them all, with every blob's size, and +// hold every manifest's content. They are read through the store's container, distribution's own (the resource store), +// and only read; the artifact sweep and the read tools ask `list` and never reach the files themselves. Moved here from +// artifact-store-tools, which read them by docker exec into this module's container until ADR 0632. + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +// storageRoot is where the registry keeps its files, inside its container. +const storageRoot = "/var/lib/registry/docker/registry/v2" + +// listScript lists, in three sections, every blob with its size, every manifest each repository holds, +// and what each tag names now. Busybox's find and stat (the store's image is Alpine): no -printf. An +// upload in progress is not listed, and nor are a tag's former targets. +const listScript = `set -e +cd ` + storageRoot + ` +echo '#blobs' +if [ -d blobs ]; then find blobs -type f -name data | xargs -r stat -c '%s %n'; fi +echo '#revisions' +if [ -d repositories ]; then find repositories -type f -path '*/_manifests/revisions/sha256/*/link'; fi +echo '#tags' +if [ -d repositories ]; then find repositories -type f -path '*/_manifests/tags/*/current/link' -exec grep -H . {} + || true; fi +echo '#end'` + +// Repo is one repository as the store's files list it. +type Repo struct { + Name string + // Revisions are the digests of every manifest it holds, tagged or not. + Revisions map[string]bool + // Tags are what each tag names now. + Tags map[string]string +} + +// Listing is the store's files: every blob with its size, and every repository. +type Listing struct { + Blobs map[string]int64 + Repos map[string]*Repo +} + +func (l *Listing) repo(name string) *Repo { + r := l.Repos[name] + if r == nil { + r = &Repo{Name: name, Revisions: map[string]bool{}, Tags: map[string]string{}} + l.Repos[name] = r + } + return r +} + +// RepoNames are the repositories in order. +func (l *Listing) RepoNames() []string { + out := make([]string, 0, len(l.Repos)) + for n := range l.Repos { + out = append(out, n) + } + sort.Strings(out) + return out +} + +// ParseListing reads listScript's output. A section that never ended means the listing was cut short, +// which is an error: a partial listing would make held things look absent. +func ParseListing(out string) (*Listing, error) { + l := &Listing{Blobs: map[string]int64{}, Repos: map[string]*Repo{}} + section := "" + ended := false + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if strings.HasPrefix(line, "#") { + section = line + ended = line == "#end" + continue + } + switch section { + case "#blobs": + size, path, ok := strings.Cut(line, " ") + n, err := strconv.ParseInt(size, 10, 64) + if !ok || err != nil { + return nil, fmt.Errorf("the store's listing has a blob line it cannot read: %q", line) + } + // blobs/sha256/ab//data + parts := strings.Split(strings.TrimPrefix(path, "./"), "/") + if len(parts) != 5 || parts[0] != "blobs" || parts[4] != "data" { + return nil, fmt.Errorf("the store's listing has a blob at an unexpected place: %q", path) + } + l.Blobs[parts[1]+":"+parts[3]] = n + case "#revisions": + // repositories//_manifests/revisions/sha256//link + name, rest, ok := strings.Cut(strings.TrimPrefix(strings.TrimPrefix(line, "./"), "repositories/"), "/_manifests/revisions/") + parts := strings.Split(rest, "/") + if !ok || len(parts) != 3 || parts[2] != "link" { + return nil, fmt.Errorf("the store's listing has a manifest at an unexpected place: %q", line) + } + l.repo(name).Revisions[parts[0]+":"+parts[1]] = true + case "#tags": + // repositories//_manifests/tags//current/link:sha256: + path, digest, ok := strings.Cut(line, ":sha256:") + name, rest, ok2 := strings.Cut(strings.TrimPrefix(strings.TrimPrefix(path, "./"), "repositories/"), "/_manifests/tags/") + tag, _, ok3 := strings.Cut(rest, "/current/link") + if !ok || !ok2 || !ok3 || tag == "" { + return nil, fmt.Errorf("the store's listing has a tag line it cannot read: %q", line) + } + l.repo(name).Tags[tag] = "sha256:" + digest + default: + return nil, fmt.Errorf("the store's listing has a line outside any section: %q", line) + } + } + if !ended { + return nil, fmt.Errorf("the store's listing was cut short (it never reached its end)") + } + return l, nil +} + +// Manifest is what one manifest names. +type Manifest struct { + MediaType string + ConfigMedia string + // Refs are the blobs it names: its configuration and its layers. + Refs []string + // Layers are its layers alone, in order. + Layers []string + // Children are the manifests an index names. + Children []string +} + +// Holder is the shape of a manifest that keeps one blob: an empty configuration and one layer. The +// controller holds every archive the mesh keeps this way (novox/hq issue 253), and keeps a named +// document the same way, under a tag. +func (m *Manifest) Holder() bool { + return m != nil && m.ConfigMedia == "application/vnd.oci.empty.v1+json" && len(m.Layers) == 1 +} + +// ParseManifest reads a manifest's content, of any of the shapes a registry holds. +func ParseManifest(raw []byte) (*Manifest, error) { + type desc struct { + MediaType string `json:"mediaType"` + Digest string `json:"digest"` + } + var v struct { + MediaType string `json:"mediaType"` + Config *desc `json:"config"` + Layers []desc `json:"layers"` + Manifests []desc `json:"manifests"` + FSLayers []struct { + BlobSum string `json:"blobSum"` + } `json:"fsLayers"` + } + if err := json.Unmarshal(raw, &v); err != nil { + return nil, fmt.Errorf("not a manifest: %v", err) + } + m := &Manifest{MediaType: v.MediaType} + if v.Config != nil && v.Config.Digest != "" { + m.ConfigMedia = v.Config.MediaType + m.Refs = append(m.Refs, v.Config.Digest) + } + for _, d := range v.Layers { + m.Refs = append(m.Refs, d.Digest) + m.Layers = append(m.Layers, d.Digest) + } + for _, d := range v.FSLayers { + m.Refs = append(m.Refs, d.BlobSum) + m.Layers = append(m.Layers, d.BlobSum) + } + for _, d := range v.Manifests { + m.Children = append(m.Children, d.Digest) + } + return m, nil +} + +// readScript prints each manifest named on its command line, each after a line naming its path. A +// manifest is JSON, which holds no raw line break inside a string, so no line of one starts with the +// marker. One the store does not have is said, not skipped. +const readScript = `cd ` + storageRoot + ` +for p in "$@"; do + echo "#manifest $p" + if [ -f "$p" ]; then cat "$p"; echo; else echo "#absent"; fi +done +echo '#end'` + +// readBatch is how many manifests one exec reads: well under any command line's limit. +const readBatch = 400 + +// Files reaches the artifact store's own files, through its own container, and only reads them. +type Files struct { + Container string + Run Runner + UID int + + mu sync.Mutex + // cache holds every manifest read: a manifest is named by its content's digest, so it never changes. + cache map[string]*Manifest +} + +// List reads the store's files. +func (s *Files) List(ctx context.Context) (*Listing, error) { + if s.Container == "" { + return nil, fmt.Errorf("this bundle was not told the artifact store's container (MESH_REGISTRY_CONTAINER)") + } + out, err := docker(ctx, s.Run, s.UID, "exec", s.Container, "sh", "-c", listScript) + if err != nil { + return nil, err + } + return ParseListing(out) +} + +// Key names one manifest in one repository. +func Key(repo, digest string) string { return repo + "@" + digest } + +// blobPath is where the store keeps a blob, under storageRoot. +func blobPath(digest string) string { + algorithm, hex, _ := strings.Cut(digest, ":") + if algorithm == "" || len(hex) < 2 || strings.ContainsAny(digest, "/ \n") { + return "" + } + return "blobs/" + algorithm + "/" + hex[:2] + "/" + hex + "/data" +} + +// Manifests reads every manifest the listing names, from the cache or the store's files. Answers each +// one read, and each one not read with why. +func (s *Files) Manifests(ctx context.Context, l *Listing) (map[string]*Manifest, map[string]string) { + need := map[string]bool{} + s.mu.Lock() + if s.cache == nil { + s.cache = map[string]*Manifest{} + } + for _, name := range l.RepoNames() { + for d := range l.Repos[name].Revisions { + if _, ok := s.cache[d]; !ok { + need[d] = true + } + } + } + s.mu.Unlock() + + digests := make([]string, 0, len(need)) + for d := range need { + digests = append(digests, d) + } + sort.Strings(digests) + failed := map[string]string{} + for start := 0; start < len(digests); start += readBatch { + batch := digests[start:min(start+readBatch, len(digests))] + read, err := s.read(ctx, batch) + s.mu.Lock() + for _, d := range batch { + switch m := read[d]; { + case err != nil: + failed[d] = err.Error() + case m == nil: + failed[d] = "the store holds no manifest content for it" + default: + s.cache[d] = m + } + } + s.mu.Unlock() + } + + got := map[string]*Manifest{} + unread := map[string]string{} + s.mu.Lock() + defer s.mu.Unlock() + for _, name := range l.RepoNames() { + for d := range l.Repos[name].Revisions { + if m, ok := s.cache[d]; ok { + got[Key(name, d)] = m + } else if why, ok := failed[d]; ok { + unread[Key(name, d)] = why + } else { + unread[Key(name, d)] = "not read" + } + } + } + return got, unread +} + +// read reads one batch of manifests. A blob that is not a manifest, or not there, is answered as nil; +// an exec that fails fails the batch. +func (s *Files) read(ctx context.Context, digests []string) (map[string]*Manifest, error) { + args := []string{"exec", s.Container, "sh", "-c", readScript, "sh"} + byPath := map[string]string{} + for _, d := range digests { + if p := blobPath(d); p != "" { + byPath[p] = d + args = append(args, p) + } + } + out, err := docker(ctx, s.Run, s.UID, args...) + if err != nil { + return nil, err + } + return parseManifests(out, byPath) +} + +// parseManifests reads readScript's output. +func parseManifests(out string, byPath map[string]string) (map[string]*Manifest, error) { + got := map[string]*Manifest{} + current := "" + var body strings.Builder + flush := func() { + if d := byPath[current]; d != "" { + m, err := ParseManifest([]byte(body.String())) + if err != nil { + m = nil + } + got[d] = m + } + current = "" + body.Reset() + } + ended := false + for _, line := range strings.Split(out, "\n") { + switch { + case strings.HasPrefix(line, "#manifest "): + flush() + current = strings.TrimPrefix(line, "#manifest ") + case line == "#absent": + if d := byPath[current]; d != "" { + got[d] = nil + } + current = "" + body.Reset() + case line == "#end": + flush() + ended = true + default: + body.WriteString(line) + body.WriteString("\n") + } + } + if !ended { + return nil, fmt.Errorf("reading the store's manifests was cut short (it never reached its end)") + } + return got, nil +} + +// Ran is what a command did: its output, its exit status, and why it never ran to an answer. +type Ran struct { + Stdout string + Stderr string + Status int + // Err is "ENOENT" when the program is not installed, or says it was ended for taking too long. + Err string +} + +// Runner runs one command, so every tool can be tested without a daemon. +type Runner func(ctx context.Context, name string, args ...string) Ran + +// ListTimeout is how long listing the store's files may take: below the runtime's thirty-second call +// limit, so a store that hangs is answered as such rather than as a call the runtime gave up on. +const ListTimeout = 15 * time.Second + +// ExecRunner runs a command on this machine, bounded by ListTimeout. +func ExecRunner(ctx context.Context, name string, args ...string) Ran { + ctx, cancel := context.WithTimeout(ctx, ListTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + var out, errb bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &errb + err := cmd.Run() + r := Ran{Stdout: out.String(), Stderr: errb.String()} + var exitErr *exec.ExitError + switch { + case errors.Is(ctx.Err(), context.DeadlineExceeded): + r.Status, r.Err = 124, fmt.Sprintf("no answer within %d s", int(ListTimeout/time.Second)) + case errors.Is(err, exec.ErrNotFound): + r.Status, r.Err = 127, "ENOENT" + case errors.As(err, &exitErr): + r.Status = exitErr.ExitCode() + case err != nil: + r.Status, r.Err = 1, err.Error() + } + return r +} + +var socketRefused = regexp.MustCompile(`(?i)permission denied.*docker.*sock|docker\.sock.*permission denied`) + +// docker runs one docker command as this account and answers its stdout. A socket that refuses the +// account is asked again through `sudo -n`, as the container runtime's own tools do; never as root +// otherwise, and never with a prompt. +func docker(ctx context.Context, run Runner, uid int, args ...string) (string, error) { + r := run(ctx, "docker", args...) + program := "docker" + if r.Status != 0 && r.Err == "" && uid != 0 && socketRefused.MatchString(r.Stderr) { + program = "sudo" + r = run(ctx, "sudo", append([]string{"-n", "docker"}, args...)...) + } + if r.Status == 0 && r.Err == "" { + return r.Stdout, nil + } + said := strings.TrimSpace(r.Stderr + "\n" + r.Stdout) + switch { + case r.Err == "ENOENT" && program == "sudo": + return "", errors.New("the runtime's socket refused this account, and sudo is not installed here to escalate with") + case r.Err == "ENOENT": + return "", errors.New("docker is not installed on this machine, so the store's files cannot be listed") + case r.Err != "": + return "", fmt.Errorf("listing the store's files did not answer: %s", r.Err) + case program == "sudo" && strings.HasPrefix(said, "sudo:"): + return "", fmt.Errorf("the runtime's socket refused this account and it may not escalate without a prompt: %s", firstLine(said)) + case strings.Contains(said, "No such container"): + return "", fmt.Errorf("the store's container is not on this machine: %s", firstLine(said)) + case strings.Contains(said, "is not running"): + return "", fmt.Errorf("the store's container is not running: %s", firstLine(said)) + } + if l := firstLine(said); l != "" { + return "", fmt.Errorf("listing the store's files failed (%d): %s", r.Status, l) + } + return "", fmt.Errorf("listing the store's files failed with status %d", r.Status) +} + +func firstLine(s string) string { + for _, l := range strings.Split(s, "\n") { + if l = strings.TrimSpace(l); l != "" { + return l + } + } + return "" +} diff --git a/modules/distribution/internal/holder/front.go b/modules/distribution/internal/holder/front.go new file mode 100644 index 000000000..44c4988af --- /dev/null +++ b/modules/distribution/internal/holder/front.go @@ -0,0 +1,82 @@ +package holder + +import ( + "fmt" + "net/http" + "net/http/httputil" + "net/url" + "strings" + "sync/atomic" +) + +// Front is the store's port, served by distribution's program in front of the registry container, which listens on its +// node's loopback only (design 51 *No build module holds a registry's write credential*): reads (the OCI distribution +// protocol's GET and HEAD) stay open to every node's container runtime; a write reaches the store only from +// distribution's own process, which writes to the loopback directly and never through the front. While the direct-write +// window is open, a build module's old build still pushes through the front as today, but never to a tag the seat holds +// published. No credential opens it: on a fresh mesh the bootstrap pushes the holders' own images to the store on its +// loopback, never through the front (design 51 *On a fresh mesh*). +type Front struct { + // Store is the registry container's loopback address. + Store *url.URL + // Window says whether the direct-write window is open. + Window *atomic.Bool + // Published says whether name holds a tag published through the seat. + Published func(name, tag string) (bool, error) + Logf func(string, ...any) + proxy *httputil.ReverseProxy +} + +// ServeHTTP answers one request of the store's port. +func (f *Front) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if f.proxy == nil { + f.proxy = httputil.NewSingleHostReverseProxy(f.Store) + } + switch r.Method { + case http.MethodGet, http.MethodHead: + f.proxy.ServeHTTP(w, r) + return + } + if f.Window == nil || !f.Window.Load() { + refuse(w, http.StatusForbidden, "DENIED", "the store takes writes from distribution's own process alone: publish through the seat's publish") + return + } + if name, tag, ok := manifestPut(r); ok && f.Published != nil { + held, err := f.Published(name, tag) + if err != nil { + refuse(w, http.StatusServiceUnavailable, "UNAVAILABLE", "whether "+name+":"+tag+" is published cannot be read, so nothing is written: "+err.Error()) + return + } + if held { + refuse(w, http.StatusForbidden, "DENIED", fmt.Sprintf("%s:%s is published through the seat; a direct write cannot overwrite it: publish through the seat's publish", name, tag)) + return + } + } + if f.Logf != nil { + f.Logf("a direct write %s %s from %s, accepted while the direct-write window is open", r.Method, r.URL.Path, r.RemoteAddr) + } + f.proxy.ServeHTTP(w, r) +} + +// manifestPut is a PUT of /v2//manifests/, a tag and not a digest. +func manifestPut(r *http.Request) (name, tag string, ok bool) { + if r.Method != http.MethodPut { + return "", "", false + } + p := strings.TrimPrefix(r.URL.Path, "/v2/") + i := strings.LastIndex(p, "/manifests/") + if i <= 0 { + return "", "", false + } + name, tag = p[:i], p[i+len("/manifests/"):] + if strings.HasPrefix(tag, "sha256:") || tag == "" { + return "", "", false + } + return name, tag, true +} + +func refuse(w http.ResponseWriter, code int, kind, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + fmt.Fprintf(w, `{"errors":[{"code":%q,"message":%q}]}`, kind, msg) +} diff --git a/modules/distribution/internal/holder/health.go b/modules/distribution/internal/holder/health.go new file mode 100644 index 000000000..1473e5639 --- /dev/null +++ b/modules/distribution/internal/holder/health.go @@ -0,0 +1,34 @@ +package holder + +import "fmt" + +// FindingReadSide is the finding of a store whose read side does not answer through the program. +const FindingReadSide = "read-side-down" + +// ProbeName is the name the health check lists on each seat: any name answers, an empty list included. +const ProbeName = Self + "/program" + +// Health is distribution's health check (design 51 *While the program is down*; ADR 0240), run by the runtime each +// minute outside the program's process: it asks `versions` of each seat it holds through the store's read side, the +// program's port (front), and answers what failed, naming the program. The finding is written while it fails and +// cleared once it answers. +func Health(front Catalog, finding func(kind, subject, words string, raised bool)) (map[string]any, error) { + answered := map[string]int{} + for _, seat := range Seats { + got, err := front.Versions(ProbeName) + if err != nil { + words := fmt.Sprintf("the store's read side does not answer %s's versions through distribution's program (port 5000): %v; is the program distribution running?", seat, err) + if finding != nil { + finding(FindingReadSide, seat, words, true) + } + return nil, fmt.Errorf("%s", words) + } + answered[seat] = len(got) + } + if finding != nil { + for _, seat := range Seats { + finding(FindingReadSide, seat, "", false) + } + } + return map[string]any{"healthy": true, "versions": answered}, nil +} diff --git a/modules/distribution/internal/holder/holder_test.go b/modules/distribution/internal/holder/holder_test.go new file mode 100644 index 000000000..7fcede3a6 --- /dev/null +++ b/modules/distribution/internal/holder/holder_test.go @@ -0,0 +1,915 @@ +package holder + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "reflect" + "strings" + "sync/atomic" + "testing" + "time" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" + artifactref "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient/reference" +) + +type memObjects map[string][]byte + +func (m memObjects) Put(n string, d []byte) error { m[n] = append([]byte(nil), d...); return nil } +func (m memObjects) Get(n string) ([]byte, error) { + if d, ok := m[n]; ok { + return d, nil + } + return nil, errors.New("no object " + n) +} +func (m memObjects) Delete(n string) error { delete(m, n); return nil } +func (m memObjects) Used() (uint64, error) { + var u uint64 + for _, d := range m { + u += uint64(len(d)) + } + return u, nil +} + +type memTransfers map[string]Transfer + +func (m memTransfers) Get(id string) (Transfer, bool, error) { t, ok := m[id]; return t, ok, nil } +func (m memTransfers) Put(t Transfer) error { m[t.ID] = t; return nil } +func (m memTransfers) Delete(id string) error { delete(m, id); return nil } +func (m memTransfers) List() ([]Transfer, error) { + var out []Transfer + for _, t := range m { + out = append(out, t) + } + return out, nil +} + +type fakeBuilder struct { + ok bool + err error +} + +func (b fakeBuilder) Recorded(string, string, string) (bool, string, error) { + return b.ok, "not numbered", b.err +} + +type fakeStore struct { + has map[string]bool + put []map[string][]byte +} + +func (s *fakeStore) Has(_, d string) (bool, error) { return s.has[d], nil } +func (s *fakeStore) Put(t PublishRequest, parts map[string][]byte) (string, error) { + s.put = append(s.put, parts) + return t.Name + "@" + t.Digest, nil +} +func (s *fakeStore) Held(string, string, string) (string, bool, error) { return "", false, nil } + +type said struct { + seat, event string + body Event +} + +type bed struct { + h *Holder + v *Verbs + store *fakeStore + said []said + findings map[string]bool + now time.Time + cat *fakeCatalog +} + +type fakeCatalog struct { + tags map[string]string // name:version -> digest + removed []string + mirrorErr error + tagErr error + indexes map[string][]string // name@digest -> the platform manifests it names +} + +func (c *fakeCatalog) Versions(name string) ([]string, error) { + var out []string + for k := range c.tags { + if n, v, _ := strings.Cut(k, ":"); n == name { + out = append(out, v) + } + } + return out, nil +} +func (c *fakeCatalog) Digest(name, version string) (string, error) { + return c.tags[name+":"+version], nil +} + +// Remove deletes by the digest, as the store does: every tag naming the digest goes with it. +func (c *fakeCatalog) Remove(name, version string) error { + c.removed = append(c.removed, name+":"+version) + d := c.tags[name+":"+version] + for k, v := range c.tags { + if n, _, _ := strings.Cut(k, ":"); n == name && v == d { + delete(c.tags, k) + } + } + return nil +} + +// RemoveTag answers as OCI.RemoveTag over registry 2.8.3 does (fakeRegistry's DELETE by tag): the store cannot +// remove a tag alone, and nothing goes. tagErr is any other failure of the store. +func (c *fakeCatalog) RemoveTag(name, version string) error { + if c.tagErr != nil { + return c.tagErr + } + return fmt.Errorf("%w: DELETE of %s:%s by its tag answered 400 DIGEST_INVALID", ErrNoTagAlone, name, version) +} +func (c *fakeCatalog) RemoveDigest(name, digest string) error { + c.removed = append(c.removed, name+"@"+digest) + return nil +} +func (c *fakeCatalog) Platforms(name, digest string) ([]string, error) { + return c.indexes[name+"@"+digest], nil +} +func (c *fakeCatalog) Mirror(source, digest string) (string, error) { + if c.mirrorErr != nil { + return "", c.mirrorErr + } + src, _ := ParseSource(source) + return c.Reference(src.Name(), digest), nil +} +func (c *fakeCatalog) Reference(name, digest string) string { + return name + "@" + digest +} + +type memMirrors map[string]MirrorRequest + +func (m memMirrors) Ask(k string, r MirrorRequest) error { + if _, ok := m[k]; !ok { + m[k] = r + } + return nil +} +func (m memMirrors) Claim(k string, r MirrorRequest, rev uint64) (bool, error) { + if cur, ok := m[k]; !ok || mirrorRev(cur) != rev { + return false, nil + } + m[k] = r + return true, nil +} +func (m memMirrors) Delete(k string) error { delete(m, k); return nil } +func (m memMirrors) List() (map[string]MirrorRequest, error) { + out := map[string]MirrorRequest{} + for k, r := range m { + out[k] = r + } + return out, nil +} +func (m memMirrors) Put(k string, r MirrorRequest) error { m[k] = r; return nil } + +// mirrorRev is a kept ask's revision in memMirrors: 1 unclaimed, 2 once claimed. +func mirrorRev(r MirrorRequest) uint64 { + if r.Claim == "" { + return 1 + } + return 2 +} + +type memHolds map[string]Hold + +func (m memHolds) Get(k string) (Hold, bool, error) { h, ok := m[k]; return h, ok, nil } +func (m memHolds) Put(h Hold) error { m[h.Key()] = h; return nil } +func (m memHolds) Delete(k string) error { delete(m, k); return nil } +func (m memHolds) List() ([]Hold, error) { + var out []Hold + for _, h := range m { + out = append(out, h) + } + return out, nil +} + +func newBed(builderOK bool) *bed { + b := &bed{store: &fakeStore{has: map[string]bool{}}, findings: map[string]bool{}, now: time.Unix(1000, 0), cat: &fakeCatalog{tags: map[string]string{}}} + b.h = &Holder{Callers: DefaultCallers(), Objects: memObjects{}, Transfers: memTransfers{}, Builder: fakeBuilder{ok: builderOK}, + Store: b.store, Now: func() time.Time { return b.now }, + Say: func(seat, event string, body Event) error { + b.said = append(b.said, said{seat, event, body}) + return nil + }, + Finding: func(kind, subject, _ string, raised bool) { b.findings[kind+" "+subject] = raised }} + b.v = &Verbs{Holder: b.h, Catalog: b.cat, Holds: memHolds{}, Mirrors: memMirrors{}} + return b +} + +// capOf is a binding's cap of n bytes. +func capOf(n uint64) func() (uint64, error) { return func() (uint64, error) { return n, nil } } + +func keys[V any](m map[string]V) []string { + var out []string + for k := range m { + out = append(out, k) + } + return out +} + +func digestOf(b []byte) string { s := sha256.Sum256(b); return "sha256:" + hex.EncodeToString(s[:]) } + +const builder = "ace.mesh-build-go" + +// TestTheHolderOfTheTwoImageSeats holds design 51's holder cases at the unit level (CAT-D1, D2, D3, D3b, D4, D4b, D5): +// one table or case per rule, the caller list and the store front's. +func TestTheHolderOfTheTwoImageSeats(t *testing.T) { + blob := []byte(strings.Repeat("a", 3000)) + part := Part{Digest: digestOf(blob), Size: int64(len(blob))} + req := func(id string) PublishRequest { + return PublishRequest{Transfer: id, Name: "m/program", Version: "0.1.4", Kind: KindBundle, Digest: part.Digest, Parts: []Part{part}} + } + + t.Run("a transfer in parts, cut and resumed, hashed, numbered by the builder, published", func(t *testing.T) { + b := newBed(true) + a, err := b.h.Publish(ArtifactSeat, builder, req("t1")) + if err != nil || !reflect.DeepEqual(a.Missing, []string{part.Digest}) { + t.Fatalf("publish = %+v, %v", a, err) + } + if _, err := b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 0, Data: blob[:1000]}); err != nil { + t.Fatal(err) + } + // Cut: the build module opens again and is told what is still missing; its next chunk goes on from there. + if a, _ := b.h.Publish(ArtifactSeat, builder, req("t1")); !reflect.DeepEqual(a.Missing, []string{part.Digest}) { + t.Errorf("resumed publish = %+v", a) + } + if _, err := b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 0, Data: blob[:1000]}); err == nil { + t.Error("a chunk sent twice was taken: the transfer goes on from the chunks held") + } + a, err = b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 1, Last: true, Data: blob[1000:]}) + if err != nil || !a.Ended || len(b.said) != 1 || b.said[0].event != EventPublished || b.said[0].body.Reference == "" { + t.Fatalf("last chunk = %+v, %v; said %+v", a, err, b.said) + } + // The reference's registry part is the seat's name, as a node-engine reads it back (ADR 0605 §9); nothing set on + // the process names a reach. + if seat, path, ok := registryForms.InRegistrySeat(b.said[0].body.Reference); !ok || seat != ArtifactSeat || path != "m/program@"+part.Digest { + t.Errorf("the published reference %q is not the seat's form", b.said[0].body.Reference) + } + if len(b.h.Objects.(memObjects)) != 0 || len(b.h.Transfers.(memTransfers)) != 0 { + t.Error("the transfer's chunks or key stayed after its end") + } + }) + t.Run("refusals", func(t *testing.T) { + for _, c := range []struct { + name string + do func(b *bed) error + want string + }{ + {"a caller not on the list", func(b *bed) error { _, err := b.h.Publish(ArtifactSeat, "ace.mesh-provisioner", req("t")); return err }, "not on the holder's caller list"}, + {"an unproven caller", func(b *bed) error { _, err := b.h.Publish(ArtifactSeat, Unproven, req("t")); return err }, "no caller the tool runner proved"}, + {"a part from another caller than the opener", func(b *bed) error { + _, _ = b.h.Publish(ArtifactSeat, builder, req("t")) + _, err := b.h.PublishPart(ArtifactSeat, "ace.mesh-build-node", PartRequest{Transfer: "t", Digest: part.Digest, Last: true, Data: blob}) + return err + }, "opened by another caller"}, + {"a part whose bytes do not match its digest", func(b *bed) error { + _, _ = b.h.Publish(ArtifactSeat, builder, req("t")) + _, err := b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t", Digest: part.Digest, Last: true, Data: []byte("other")}) + return err + }, "hash to"}, + {"a chunk over 1 MiB", func(b *bed) error { + _, _ = b.h.Publish(ArtifactSeat, builder, req("t")) + _, err := b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t", Digest: part.Digest, Data: make([]byte, MaxPart+1)}) + return err + }, "over the"}, + {"a new transfer at the cap", func(b *bed) error { + b.h.Cap = capOf(100) + _, err := b.h.Publish(ArtifactSeat, builder, req("t")) + return err + }, "at their cap"}, + {"a version the builder did not number", func(b *bed) error { + b.h.Builder = fakeBuilder{ok: false} + b.store.has[part.Digest] = true // held already: the publish ends at once + _, err := b.h.Publish(ArtifactSeat, builder, req("t")) + return err + }, "the builder did not number it"}, + {"an image on the artifact seat", func(b *bed) error { + r := req("t") + r.Kind = KindImage + _, err := b.h.Publish(ArtifactSeat, builder, r) + return err + }, "holds bundles and archives"}, + {"remove of a version with no tag", func(b *bed) error { + _, err := b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/program", Version: "0.1.4", Reason: "branch ended"}) + return err + }, "has no tag"}, + {"a retention removal from another caller than the sweep", func(b *bed) error { + _, err := b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/program", Digest: part.Digest, Reason: ReasonRetention}) + return err + }, "artifact sweep's alone"}, + {"a retention removal of a held archive", func(b *bed) error { + _ = b.v.HoldOne(ArtifactSeat, "novox.artifact-store-tools", HoldRequest{Name: "m/a", Kind: KindArchive, Digest: part.Digest}) + _, err := b.v.Remove(ArtifactSeat, "novox.artifact-store-tools", RemoveRequest{Name: "m/a", Digest: part.Digest, Reason: ReasonRetention}) + return err + }, "is held"}, + {"a hold of an image", func(b *bed) error { + return b.v.HoldOne(ImageSeat, "novox.artifact-store-tools", HoldRequest{Name: "m/i", Kind: KindImage, Digest: part.Digest}) + }, "never held"}, + {"a hold from another caller", func(b *bed) error { + return b.v.HoldOne(ArtifactSeat, "novox.mesh-build-go", HoldRequest{Name: "m/a", Kind: KindArchive, Digest: part.Digest}) + }, "not on the holder's caller list"}, + {"a release from a caller not on the list", func(b *bed) error { + _, err := b.v.Release(ArtifactSeat, "novox.mesh-build-go", HoldRequest{Name: "m/a", Digest: part.Digest}) + return err + }, "not on the holder's caller list"}, + {"a release in distribution's own name", func(b *bed) error { + _, err := b.v.Release(ArtifactSeat, "novox.distribution", HoldRequest{Name: "m/a", Digest: part.Digest}) + return err + }, "in its own name"}, + {"a mirror by no digest", func(b *bed) error { + _, err := b.v.Mirror(ImageSeat, "novox.mesh-build-image", MirrorRequest{Source: "docker.io/library/nats:2"}) + return err + }, "pinned digest"}, + } { + b := newBed(true) + if err := c.do(b); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: err = %v, want one saying %q", c.name, err, c.want) + } + } + if _, err := ParseCallers(`{"release":["distribution"]}`); err == nil { + t.Error("a caller list putting distribution on release's list is taken") + } + }) + t.Run("only the missing layers are sent, and an idle transfer is dropped", func(t *testing.T) { + b := newBed(true) + other := []byte("layer two") + r := req("t2") + r.Kind, r.Parts = KindImage, []Part{part, {Digest: digestOf(other), Size: int64(len(other))}} + b.store.has[part.Digest] = true + a, err := b.h.Publish(ImageSeat, builder, r) + if err != nil || !reflect.DeepEqual(a.Missing, []string{digestOf(other)}) { + t.Fatalf("publish = %+v, %v; want only the layer the store lacks", a, err) + } + b.now = b.now.Add(TransferIdle) + if dropped, _ := b.h.DropIdle(); !reflect.DeepEqual(dropped, []string{"t2"}) { + t.Errorf("DropIdle = %v, want t2 after 24 hours with no part", dropped) + } + // Running review F2, ADR 0611 §3: the drop follows each transfer's idle key. The open arms it for 24 hours; its + // end re-arms a transfer that took a part since for the rest, and drops one idle 24 hours, its chunks with it. + b = newBed(true) + armed := map[string]time.Duration{} + b.h.Idle = func(id string, ttl time.Duration) error { armed[id] = ttl; return nil } + if _, err := b.h.Publish(ArtifactSeat, builder, req("t4")); err != nil || armed["t4"] != TransferIdle { + t.Fatalf("publish = %v, armed %v; want t4's idle key for %s", err, armed, TransferIdle) + } + b.now = b.now.Add(time.Hour) + _, _ = b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t4", Digest: part.Digest, Data: blob[:100]}) + b.now = b.now.Add(TransferIdle - time.Hour) + if dropped, err := b.h.Expired("t4"); err != nil || dropped || armed["t4"] != time.Hour { + t.Errorf("the key's end an hour after a part: dropped %v, %v, armed %v; want t4 kept and armed for 1h", dropped, err, armed) + } + b.now = b.now.Add(time.Hour) + if dropped, err := b.h.Expired("t4"); err != nil || !dropped { + t.Errorf("the key's end 24 hours after the last part: dropped %v, %v; want t4 dropped", dropped, err) + } + if _, found, _ := b.h.Transfers.Get("t4"); found || len(b.h.Objects.(memObjects)) != 0 { + t.Errorf("t4 after its drop: kept %v, objects %v; want both gone", found, b.h.Objects) + } + if dropped, err := b.h.Expired("t4"); err != nil || dropped { + t.Errorf("the end of a key whose transfer is gone: %v, %v; want nothing done", dropped, err) + } + b.h.Idle = func(string, time.Duration) error { return errors.New("bus down") } + if _, err := b.h.Publish(ArtifactSeat, builder, req("t5")); err == nil || !strings.Contains(err.Error(), "idle bound") { + t.Errorf("a publish whose idle key is not written = %v; want it failed loudly", err) + } + // Seams note 2: registry_incoming's drop is a hand act recorded through mesh-hand-acts before anything goes; + // an act not recorded leaves the transfer open. + var acts []meshhandactsclient.Request + b.h.Record = func(q meshhandactsclient.Request) error { return errors.New("mesh-hand-acts has no holder") } + if _, err := b.h.DropByHand("t5", "person.jochen", "stuck"); err == nil || !strings.Contains(err.Error(), "stays open") { + t.Errorf("a drop whose act is not recorded = %v; want refused, t5 open", err) + } + b.h.Record = func(q meshhandactsclient.Request) error { acts = append(acts, q); return nil } + if _, err := b.h.DropByHand("t5", "person.jochen", ""); err == nil || len(acts) != 0 { + t.Errorf("a drop with no why = %v, acts %v; want refused, nothing recorded", err, acts) + } + if got, err := b.h.DropByHand("t5", "person.jochen", "stuck"); err != nil || got.ID != "t5" || len(acts) != 1 || acts[0].Why != "stuck" || acts[0].Cause != "registry_incoming" || !strings.Contains(acts[0].What, "t5") || !strings.Contains(acts[0].What, "person.jochen") { + t.Errorf("a drop by hand = %+v, %v, acts %+v; want t5 dropped and its act recorded", got, err, acts) + } + if _, found, _ := b.h.Transfers.Get("t5"); found { + t.Errorf("t5 kept after its drop by hand") + } + }) + t.Run("incoming's finding at 80 %, cleared below", func(t *testing.T) { + b := newBed(true) + b.h.Cap = capOf(3500) + _, _ = b.h.Publish(ArtifactSeat, builder, req("t3")) + _, _ = b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t3", Digest: part.Digest, Data: blob[:2800]}) + if !b.findings["incoming-full incoming"] { + t.Error("2800 of 3500 bytes wrote no finding") + } + _, _ = b.h.Drop("t3") + if b.findings["incoming-full incoming"] { + t.Error("the finding stayed after the transfer was dropped") + } + // A refusal for want of room stays loud on incoming until the holder takes a publish again. + b.h.Cap = capOf(100) + _, _ = b.h.Publish(ArtifactSeat, builder, req("t4")) + if !b.findings["publish-refused incoming"] { + t.Errorf("a refusal at the cap raised %v, want publish-refused on incoming", b.findings) + } + b.h.Cap = capOf(1 << 20) + b.store.has[part.Digest] = true + if _, err := b.h.Publish(ArtifactSeat, builder, req("t5")); err != nil || b.findings["publish-refused incoming"] { + t.Errorf("the next publish taken = %v, findings %v: want the refusal at the cap cleared", err, b.findings) + } + }) + t.Run("the sweep's removal, hold and release", func(t *testing.T) { + b := newBed(true) + sweep := "novox.artifact-store-tools" + if _, err := b.v.Remove(ArtifactSeat, sweep, RemoveRequest{Name: "m/a", Digest: part.Digest, Kind: KindArchive, Reason: ReasonRetention}); err != nil { + t.Fatal(err) + } + if err := b.v.HoldOne(ArtifactSeat, sweep, HoldRequest{Name: "m/b", Kind: KindArchive, Digest: part.Digest}); err != nil { + t.Fatal(err) + } + if released, err := b.v.Release(ArtifactSeat, "person.jochen", HoldRequest{Name: "m/b", Digest: part.Digest, Why: "by hand"}); err != nil || !released { + t.Errorf("the operator's session releasing through registry_holds: %v, %v", released, err) + } + // A release of what is not held answers released false, never an error, and says nothing (the seat's definition). + if released, err := b.v.Release(ArtifactSeat, sweep, HoldRequest{Name: "m/b", Digest: part.Digest, Why: "eligible"}); err != nil || released { + t.Errorf("a release of what is not held: %v, %v; want false and no error", released, err) + } + var events []string + for _, s := range b.said { + events = append(events, s.event+" "+s.body.By) + } + if want := []string{"removed " + sweep, "held " + sweep, "released person.jochen"}; !reflect.DeepEqual(events, want) { + t.Errorf("said %v, want %v", events, want) + } + b.cat.tags["m/p:0.1.5-feat-x.1"] = part.Digest + if _, err := b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/p", Version: "0.1.5-feat-x.1", Reason: "branch merged"}); err != nil || b.cat.tags["m/p:0.1.5-feat-x.1"] != "" { + t.Errorf("the builder's removal of a tagged version: %v", err) + } + // Security review F1 on registry 2.8.3: a reproducible build gives a branch's tagged version and the trunk's + // numbered one one digest; the store cannot remove the tag alone, so the version is kept whole, the numbered one + // stays pullable, and remove answers and says kept (shared-digest, the other tags): no refusal, so the builder + // raises no retry. + b.cat.tags["m/p:0.1.6-feat-y.1"], b.cat.tags["m/p:0.1.6"] = part.Digest, part.Digest + got, err := b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/p", Version: "0.1.6-feat-y.1", Reason: "branch merged"}) + if err != nil || got.Removed || !got.Kept || got.Reason != ReasonSharedDigest || !reflect.DeepEqual(got.Tags, []string{"0.1.6"}) { + t.Fatalf("the removal of a tagged version sharing its digest = %+v, %v; want kept, shared-digest, tags [0.1.6], no error", got, err) + } + if b.cat.tags["m/p:0.1.6-feat-y.1"] != part.Digest || b.cat.tags["m/p:0.1.6"] != part.Digest { + t.Errorf("tags after the tagged version's removal: %v; want both kept at %s", b.cat.tags, part.Digest) + } + if last := b.said[len(b.said)-1]; last.event != EventKept || last.body.Reason != ReasonSharedDigest || last.body.Digest != part.Digest || !reflect.DeepEqual(last.body.Tags, []string{"0.1.6"}) { + t.Errorf("said %s %+v; want kept, shared-digest, its digest and the tags [0.1.6]", last.event, last.body) + } + if got, err := b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/p", Version: "0.1.5-feat-v.1", Reason: "branch merged"}); err != nil || !got.Removed || got.Kept { + t.Errorf("the removal of a version already gone = %+v, %v; want removed", got, err) + } + // Any other failure of the store removes nothing and is refused naming the shared digest. + b.cat.tags["m/p:0.1.7-feat-z.1"], b.cat.tags["m/p:0.1.7"] = part.Digest, part.Digest + b.cat.tagErr = errors.New("the store answered 500") + said := len(b.said) + if _, err := b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/p", Version: "0.1.7-feat-z.1", Reason: "branch merged"}); err == nil || !strings.Contains(err.Error(), "shares its digest") || b.cat.tags["m/p:0.1.7"] != part.Digest || len(b.said) != said { + t.Errorf("a store failing otherwise: %v, tags %v; want refused, 0.1.7 kept, nothing said", err, b.cat.tags) + } + b.cat.tagErr = nil + // The store's own answers, as registry 2.8.3 gives them: DELETE by tag removes nothing (ErrNoTagAlone); by + // digest the manifest goes with every tag naming it. + r283 := newFakeRegistry() + sts := httptest.NewServer(r283) + defer sts.Close() + u, _ := url.Parse(sts.URL) + o := &OCI{Base: u} + manifest := []byte(`{"schemaVersion":2}`) + r283.manifests["m/p:0.1.8-feat-w.1"], r283.manifests["m/p:0.1.8"] = manifest, manifest + if err := o.RemoveTag("m/p", "0.1.8-feat-w.1"); !errors.Is(err, ErrNoTagAlone) || r283.manifests["m/p:0.1.8-feat-w.1"] == nil { + t.Errorf("RemoveTag on 2.8.3 = %v; want ErrNoTagAlone and the tag kept", err) + } + if err := o.Remove("m/p", "0.1.8"); err != nil || len(r283.manifests) != 0 { + t.Errorf("Remove by digest on 2.8.3 = %v, %d manifests left; want both tags gone", err, len(r283.manifests)) + } + }) + // CAT-AS1: what the sweep did over the store's door is the holder's: a removal for retention of an index removes + // its platform manifests first, but those a tagged or held index of the repository names; a manifest such an index + // names is refused; OCI.Platforms reads an index's from the store. + t.Run("an index's platforms go with it, but those a kept index names", func(t *testing.T) { + d := func(s string) string { return "sha256:" + strings.Repeat(s, 64) } + sweep := "novox.artifact-store-tools" + name := "upstream/docker.io/library/alpine" + for _, c := range []struct { + case_, remove string + tagged, held string // the kept index, by a tag or a hold; none when empty + removed []string + refused string + }{ + {case_: "no kept index", remove: d("1"), removed: []string{d("a"), d("b"), d("1")}}, + {case_: "a tagged index shares a platform", remove: d("1"), tagged: d("2"), removed: []string{d("a"), d("1")}}, + {case_: "a held index shares a platform", remove: d("1"), held: d("2"), removed: []string{d("a"), d("1")}}, + {case_: "a platform of a held index", remove: d("b"), held: d("2"), refused: "is kept by the index " + d("2")}, + {case_: "an image that is no index", remove: d("c"), removed: []string{d("c")}}, + } { + b := newBed(true) + b.cat.indexes = map[string][]string{name + "@" + d("1"): {d("a"), d("b")}, name + "@" + d("2"): {d("b")}} + if c.tagged != "" { + b.cat.tags[name+":3.20"] = c.tagged + } + if c.held != "" { + if err := b.v.HoldOne(ImageSeat, sweep, HoldRequest{Name: name, Digest: c.held}); err != nil { + t.Fatal(err) + } + } + _, err := b.v.Remove(ImageSeat, sweep, RemoveRequest{Name: name, Digest: c.remove, Reason: ReasonRetention}) + var removed []string + for _, r := range b.cat.removed { + removed = append(removed, strings.TrimPrefix(r, name+"@")) + } + if c.refused != "" { + if err == nil || !strings.Contains(err.Error(), c.refused) || len(removed) != 0 { + t.Errorf("%s: %v, removed %v; want refused saying %q and nothing removed", c.case_, err, removed, c.refused) + } + continue + } + if err != nil || !reflect.DeepEqual(removed, c.removed) { + t.Errorf("%s: %v, removed %v, want %v", c.case_, err, removed, c.removed) + } + } + store := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v2/" + name + "/manifests/" + d("1"): + _, _ = w.Write([]byte(`{"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"digest":"` + d("a") + `"},{"digest":"` + d("b") + `"}]}`)) + case "/v2/" + name + "/manifests/" + d("c"): + _, _ = w.Write([]byte(`{"mediaType":"application/vnd.oci.image.manifest.v1+json","layers":[]}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer store.Close() + u, _ := url.Parse(store.URL) + o := &OCI{Base: u} + for digest, want := range map[string][]string{d("1"): {d("a"), d("b")}, d("c"): nil, d("9"): nil} { + if got, err := o.Platforms(name, digest); err != nil || !reflect.DeepEqual(got, want) { + t.Errorf("Platforms(%s) = %v, %v, want %v", digest[:10], got, err, want) + } + } + }) + t.Run("the store's port: reads open, writes from distribution alone, and the direct-write window", func(t *testing.T) { + store := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) })) + defer store.Close() + u, _ := url.Parse(store.URL) + var open atomic.Bool + f := &Front{Store: u, Window: &open, Published: func(name, tag string) (bool, error) { return name == "m/i" && tag == "0.1.3", nil }} + auth := "" + do := func(method, path string) int { + rec := httptest.NewRecorder() + req := httptest.NewRequest(method, path, nil) + if auth != "" { + req.Header.Set("Authorization", "Bearer "+auth) + } + f.ServeHTTP(rec, req) + return rec.Code + } + for _, c := range []struct { + open bool + method, path string + want int + }{ + {false, http.MethodGet, "/v2/m/i/manifests/0.1.3", http.StatusCreated}, + {false, http.MethodHead, "/v2/m/i/blobs/sha256:aa", http.StatusCreated}, + {false, http.MethodPut, "/v2/m/i/manifests/0.1.9", http.StatusForbidden}, + {false, http.MethodPost, "/v2/m/i/blobs/uploads/", http.StatusForbidden}, + {true, http.MethodPost, "/v2/m/i/blobs/uploads/", http.StatusCreated}, + {true, http.MethodPut, "/v2/m/i/manifests/0.1.9", http.StatusCreated}, + {true, http.MethodPut, "/v2/m/i/manifests/0.1.3", http.StatusForbidden}, // published through the seat + } { + open.Store(c.open) + if got := do(c.method, c.path); got != c.want { + t.Errorf("window open %v: %s %s = %d, want %d", c.open, c.method, c.path, got, c.want) + } + } + // No credential opens the front (the lead's ENG-5 ruling): a write carrying a token, with the window closed, is + // refused as any other. + open.Store(false) + auth = "s3cret" + if got := do(http.MethodPut, "/v2/gitea/program/manifests/0.1.0"); got != http.StatusForbidden { + t.Errorf("a write with a bearer token and the window closed = %d, want %d", got, http.StatusForbidden) + } + }) + t.Run("mirror copies an outside image by its pinned digest, every platform, only the parts the store lacks", func(t *testing.T) { + up, store := newFakeRegistry(), newFakeRegistry() + ups, sts := httptest.NewServer(up), httptest.NewServer(store) + defer ups.Close() + defer sts.Close() + up.token, up.realm = "anon", ups.URL+"/token" + host := strings.TrimPrefix(ups.URL, "http://") + repo := "library/nats" + layer, config := []byte(strings.Repeat("l", 5000)), []byte(`{"os":"linux"}`) + up.blobs[repo+"@"+digestOf(layer)], up.blobs[repo+"@"+digestOf(config)] = layer, config + img, _ := json.Marshal(map[string]any{"schemaVersion": 2, "mediaType": mediaManifest, + "config": map[string]any{"mediaType": "application/vnd.oci.image.config.v1+json", "digest": digestOf(config), "size": len(config)}, + "layers": []map[string]any{{"mediaType": mediaLayer, "digest": digestOf(layer), "size": len(layer)}}}) + up.manifests[repo+":"+digestOf(img)], up.types[repo+":"+digestOf(img)] = img, mediaManifest + idx, _ := json.Marshal(map[string]any{"schemaVersion": 2, "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": []map[string]any{{"mediaType": mediaManifest, "digest": digestOf(img), "size": len(img)}}}) + up.manifests[repo+":"+digestOf(idx)] = idx + base, _ := url.Parse(sts.URL) + o := &OCI{Base: base, Upstream: &Upstream{Scheme: "http"}} + name := "upstream/" + host + "/" + repo + store.blobs[name+"@"+digestOf(config)] = config // held already: not fetched again + ref, err := o.Mirror(host+"/"+repo+":2", digestOf(idx)) + if err != nil { + t.Fatal(err) + } + if want := name + "@" + digestOf(idx); ref != want { + t.Errorf("reference %s, want %s", ref, want) + } + if store.manifests[name+":"+digestOf(idx)] == nil || store.manifests[name+":"+digestOf(img)] == nil || store.blobs[name+"@"+digestOf(layer)] == nil { + t.Errorf("the store holds %v and %v, want the index, its image and the layer", keys(store.manifests), keys(store.blobs)) + } + if !reflect.DeepEqual(up.gets, []string{digestOf(layer)}) { + t.Errorf("blobs fetched %v, want only the layer the store lacked", up.gets) + } + up.manifests[repo+":"+digestOf(idx)] = []byte(`{"tampered":true}`) + if _, err := o.Mirror(host+"/"+repo, digestOf(idx)); err != nil { + t.Errorf("a copy held whole is answered at once, without asking the upstream: %v", err) + } + delete(store.manifests, name+":"+digestOf(idx)) + if _, err := o.Mirror(host+"/"+repo, digestOf(idx)); err == nil || !strings.Contains(err.Error(), "does not hash") { + t.Errorf("a manifest whose bytes are not its digest: %v", err) + } + for in, want := range map[string]string{"nats:2": "upstream/docker.io/library/nats", "ghcr.io/o/p@sha256:ab": "upstream/ghcr.io/o/p", "localhost:5000/x": "upstream/localhost:5000/x"} { + if s, _ := ParseSource(in); s.Name() != want { + t.Errorf("ParseSource(%q) = %s, want %s", in, s.Name(), want) + } + } + }) + t.Run("a late publish names a held bundle with no size: tagged at the store's size, nothing sent", func(t *testing.T) { + store := newFakeRegistry() + sts := httptest.NewServer(store) + defer sts.Close() + base, _ := url.Parse(sts.URL) + o := &OCI{Base: base} + blob := []byte(strings.Repeat("b", 4321)) + store.blobs["m/tools@"+digestOf(blob)] = blob + ref, err := o.Put(PublishRequest{Name: "m/tools", Version: "0.1.4", Kind: KindBundle, Digest: digestOf(blob), + Parts: []Part{{Digest: digestOf(blob)}}}, map[string][]byte{}) + if err != nil || ref != "m/tools@"+digestOf(blob) { + t.Fatalf("Put = %q, %v", ref, err) + } + var man struct { + Layers []struct { + Size int64 `json:"size"` + } `json:"layers"` + } + if err := json.Unmarshal(store.manifests["m/tools:0.1.4"], &man); err != nil || len(man.Layers) != 1 || man.Layers[0].Size != int64(len(blob)) { + t.Errorf("the version's manifest %s, want one layer of %d bytes", store.manifests["m/tools:0.1.4"], len(blob)) + } + if _, err := o.Put(PublishRequest{Name: "m/tools", Version: "0.1.5", Kind: KindBundle, Digest: "sha256:" + strings.Repeat("e", 64), + Parts: []Part{{Digest: "sha256:" + strings.Repeat("e", 64)}}}, map[string][]byte{}); err == nil { + t.Error("a digest the store does not hold was tagged") + } + }) + t.Run("mirror answers at once and says the copy's end on the seat's events", func(t *testing.T) { + b := newBed(true) + d := "sha256:" + strings.Repeat("c", 64) + ref, err := b.v.Mirror(ImageSeat, "novox.mesh-build-image", MirrorRequest{Source: "docker.io/library/nats:2", Digest: d}) + want := "container-image-registry/upstream/docker.io/library/nats@" + d + key := MirrorsKey("upstream/docker.io/library/nats", d) + asked := b.v.Mirrors.(memMirrors) + if err != nil || ref != want || len(b.said) != 0 || asked[key].By != "novox.mesh-build-image" { + t.Fatalf("mirror = %q, %v, said %v, asks %v: want the reference at once, the copy asked, nothing said yet", ref, err, b.said, asked) + } + if strings.ContainsAny(key, ":@") { + t.Errorf("the ask's key %q holds characters a key may not", key) + } + if err := b.v.Mirrored(asked[key]); err != nil || len(b.said) != 1 || b.said[0].event != EventPublished || + b.said[0].body.Reference != want || b.said[0].body.Kind != KindUpstream || len(asked) != 0 { + t.Fatalf("the copy's end = %v, said %+v, asks %v: want published with the reference and the ask gone", err, b.said, asked) + } + // One copy, said once: two followers on the same ask; the second sees it claimed and passes, and the outcome + // carries the ask's id as its event id. + _, _ = b.v.Mirror(ImageSeat, "novox.mesh-build-image", MirrorRequest{Source: "nats", Digest: d}) + ask := asked[key] + copies := len(b.said) + for _, follower := range []string{"one", "two"} { + if err := b.v.Take(key, ask, 1, false, follower); err != nil { + t.Fatalf("Take by %s: %v", follower, err) + } + } + if len(b.said) != copies+1 || ask.ID == "" || b.said[copies].body.ID != ask.ID || len(asked) != 0 { + t.Fatalf("two followers on one ask said %+v (ask id %q), asks %v: want one copy, said once under the ask's id", b.said[copies:], ask.ID, asked) + } + b.said = b.said[:copies] + // registry_mirrors (seams review note 1): a kept copy is read, asked again with its claim let go, and dropped + // by hand, which says refused under the ask's id with who and why. + _, _ = b.v.Mirror(ImageSeat, "novox.mesh-build-image", MirrorRequest{Source: "nats", Digest: d}) + asked[key] = MirrorRequest{Source: asked[key].Source, Digest: d, ID: asked[key].ID, Claim: "cut-follower"} + if now, err := b.v.MirrorsNow(); err != nil || now[key].Claim != "cut-follower" { + t.Fatalf("MirrorsNow = %v, %v; want the claimed ask", now, err) + } + if r, err := b.v.MirrorAgain(key); err != nil || r.Claim != "" || asked[key].Claim != "" { + t.Errorf("MirrorAgain = %+v, %v (kept %+v); want the claim let go", r, err, asked[key]) + } + if _, err := b.v.MirrorDrop(key, "person.jochen", ""); err == nil { + t.Error("a drop with no why is taken") + } + if _, err := b.v.MirrorDrop(key, "person.jochen", "upstream gone"); err != nil || len(asked) != 0 || + b.said[copies].event != EventRefused || b.said[copies].body.ID == "" || !strings.Contains(b.said[copies].body.Reason, "dropped by hand by person.jochen: upstream gone") { + t.Errorf("MirrorDrop = %v, said %+v, asks %v; want refused said under the ask's id and the ask gone", err, b.said[copies:], asked) + } + b.said = b.said[:copies] + b.cat.mirrorErr = errors.New("the upstream said 404") + _, _ = b.v.Mirror(ImageSeat, "novox.mesh-build-image", MirrorRequest{Source: "nats", Digest: d}) + if err := b.v.Mirrored(asked[key]); err != nil || b.said[1].event != EventRefused || !strings.Contains(b.said[1].body.Reason, "404") || len(asked) != 0 { + t.Errorf("a failed copy = %v, said %+v: want refused with why", err, b.said) + } + }) + t.Run("the windows", func(t *testing.T) { + nodes := []NodeRead{ + {Node: "ace", Serves: true, Stated: []string{NewNamesCapability}, NamesArtifact: true, BuildModules: map[string]bool{"mesh-build-go": true}}, + {Node: "g14", NamesArtifact: true, BuildModules: map[string]bool{"mesh-build-go": false}}, // away, old node-engine + {Node: "novox", Serves: true, Stated: nil, NamesArtifact: false}, + } + if got := HoldingOldName(nodes, nil); !reflect.DeepEqual(got, []string{"g14"}) { + t.Errorf("old name held by %v, want g14 (no manifest-reads: it states nothing)", got) + } + if got := HoldingDirectWrites(nodes, nil); !reflect.DeepEqual(got, []string{"g14"}) { + t.Errorf("direct writes held by %v, want g14", got) + } + t0 := time.Unix(0, 0) + all := []string{"ace", "g14", "novox"} + w, closed, _ := Window{Name: OldNameWindow}.Step(all, []string{"g14"}, t0) + if closed != nil || !w.Open { + t.Fatalf("Step = %+v, %+v", w, closed) + } + if _, _, overdue := w.Step(all, []string{"g14"}, t0.Add(WindowFindingAfter)); !reflect.DeepEqual(overdue, []string{"g14"}) { + t.Errorf("seven days: overdue %v", overdue) + } + // Security review F3: a read naming no node (an empty node_haves) judges nothing; the window stays open. + if same, shut, _ := w.Step(nil, nil, t0.Add(time.Minute)); shut != nil || !same.Open || !reflect.DeepEqual(same.Holding, []string{"g14"}) { + t.Errorf("a read naming no node: %+v, closed %+v; want the window open as it was", same, shut) + } + w, _ = w.Drop("g14", "person.jochen", "retired", t0) + w, closed, _ = w.Step(all, HoldingOldName(nodes, w.Dropped), t0.Add(time.Hour)) + if closed == nil || closed.Window != OldNameWindow || !reflect.DeepEqual(closed.LastHolding, []string{"g14"}) || len(closed.Dropped) != 1 { + t.Errorf("closing = %+v", closed) + } + // Once closed, it stays closed: a node holding it later opens nothing. + if again, shut, _ := w.Step(all, []string{"g14"}, t0.Add(2*time.Hour)); shut != nil || again.Open { + t.Errorf("a closed window stepped on a holder: %+v, closed %+v; want it closed, said once", again, shut) + } + }) + t.Run("the windows' driver: every node read on each wake, closed once, a paused build agent's same build counted", func(t *testing.T) { + del := &fakeDelivery{haves: []NodeHave{ + {"ace", "mesh-build-go", "fp-new"}, {"novox", "mesh-build-go", "fp-new"}, {"g14", "mesh-build-go", "fp-old"}, + {"g14", "node", ""}, {"ace", "gitea", "x"}, {"novox", "gitea", "x"}, + }, stated: map[string][]string{"ace": {NewNamesCapability}, "novox": {NewNamesCapability}}} + st := &memWindows{w: map[string]Window{}} + st.pub.Learn("mesh-build-go", "fp-new") + var closed []string + found := map[string]bool{} + now := time.Unix(0, 0) + d := &Driver{Delivery: del, State: st, Callers: func() (Callers, error) { return DefaultCallers(), nil }, Now: func() time.Time { return now }, + Closed: func(c Closed) error { closed = append(closed, c.Window+" "+c.Seat); return nil }, + Finding: func(kind, subject, _ string, raised bool) { found[kind+" "+subject] = raised }} + if _, err := d.Wake("start"); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(st.w[OldNameWindow].Holding, []string{"g14"}) || !reflect.DeepEqual(st.w[DirectWritesWindow].Holding, []string{"g14"}) { + t.Errorf("windows = %+v, want g14 holding both (away: states nothing; runs the old build)", st.w) + } + // The window's record says why g14 holds it: its manifest-reads timed out, counted as stating nothing. + if why := st.w[OldNameWindow].Why["g14"]; !strings.Contains(why, "did not answer") || !strings.Contains(why, "timeout") { + t.Errorf("why g14 holds the old name = %q, want the timeout said", why) + } + // Security review F3: a wake whose node_haves names no node closes neither window. + haves := del.haves + del.haves = nil + if _, err := d.Wake("delivered"); err != nil || !st.w[OldNameWindow].Open || !st.w[DirectWritesWindow].Open || len(closed) != 0 { + t.Fatalf("a wake on an empty node_haves: %v, windows %+v, closed %v; want both open, none said", err, st.w, closed) + } + del.haves = haves + now = now.Add(WindowFindingAfter) + _, _ = d.Wake("delivered") + if !found[FindingWindowHeld+" "+OldNameWindow+".g14"] { + t.Errorf("findings %v, want g14 named after seven days", found) + } + del.haves[2].FP, del.stated["g14"] = "fp-new", []string{NewNamesCapability} + if _, err := d.Wake("manifest-reads-changed on g14"); err != nil { + t.Fatal(err) + } + _, _ = d.Wake("delivered") + if want := []string{OldNameWindow + " " + OldSeat, DirectWritesWindow + " "}; !reflect.DeepEqual(closed, want) { + t.Errorf("closed %v, want each window said once, %v", closed, want) + } + if found[FindingWindowHeld+" "+OldNameWindow+".g14"] { + t.Error("the finding stays after the window closed") + } + }) + t.Run("the health check: versions of each seat through the program's port; down names the program, then clears", func(t *testing.T) { + front := &downCatalog{fakeCatalog: fakeCatalog{tags: map[string]string{}}, down: true} + found := map[string]bool{} + f := func(kind, subject, _ string, raised bool) { found[kind+" "+subject] = raised } + if _, err := Health(front, f); err == nil || !strings.Contains(err.Error(), "program distribution") || !found[FindingReadSide+" "+ImageSeat] { + t.Errorf("program stopped: %v, findings %v", err, found) + } + front.down = false + if _, err := Health(front, f); err != nil || found[FindingReadSide+" "+ImageSeat] { + t.Errorf("program back: %v, findings %v", err, found) + } + }) + t.Run("the manifest", func(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Provides []struct{ Name string } `json:"provides"` + Serves map[string]any `json:"serves"` + Claims []struct { + Name string `json:"name"` + Serves []string `json:"serves"` + } `json:"claims"` + Resources []map[string]any `json:"resources"` + Consumes []string `json:"consumes"` + Packages map[string]string `json:"packages"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + // The builder's question is its published package, taken by its series (ADR 0634 §1). + if m.Packages["builds"] != "0.1" { + t.Errorf("packages %v: want builds of series 0.1", m.Packages) + } + // E15: the provision artifact-store retired (ADR 0632 §3): distribution provides and serves nothing, and the + // store is found by its seats' holder and the listen it opens to the mesh. + if len(m.Provides) != 0 || len(m.Serves) != 0 { + t.Errorf("provides = %+v, serves = %v, want neither: the provision artifact-store retired", m.Provides, m.Serves) + } + claims := map[string][]string{} + for _, c := range m.Claims { + claims[c.Name] = c.Serves + } + if !reflect.DeepEqual(claims[ImageSeat], VerbsOf(ImageSeat)) || !reflect.DeepEqual(claims[ArtifactSeat], VerbsOf(ArtifactSeat)) { + t.Errorf("claims = %v, want each seat serving its verbs", claims) + } + if _, ok := claims[OldSeat]; !ok { + t.Error("the old seat mesh-artifact-store is no longer claimed while its window is open") + } + if !reflect.DeepEqual(m.Consumes, []string{"mesh-delivery-view.delivered", "node-engine.manifest-reads-changed"}) { + t.Errorf("consumes = %v, want delivered and manifest-reads-changed (the windows' wakes)", m.Consumes) + } + // E14: the registry container, on the loopback, is listed before the program, so the port is free when it starts. + store, program := -1, -1 + for i, r := range m.Resources { + switch r["id"] { + case "store": + store = i + if ports, _ := r["ports"].([]any); len(ports) != 1 || ports[0] != "127.0.0.1:5001:5000" { + t.Errorf("the store's ports = %v, want the loopback only", r["ports"]) + } + // The check of another kind beside the program's tool (ADR 0227 rule 8, ADR 0240 rule 2; the operator, + // 2026-11-04): the runtime asks the store's /v2/ through the program's front, its endpoint registry. + if h, _ := r["health"].(map[string]any); h["kind"] != "http" || h["endpoint"] != "registry" || h["path"] != "/v2/" { + t.Errorf("the store's health = %v, want http on the endpoint registry asking /v2/", r["health"]) + } + case "program": + program = i + if h, _ := r["health"].(map[string]any); h["kind"] != "tool" || h["tool"] != "registry_health" { + t.Errorf("the program's health = %v, want the tool registry_health, run outside its process", r["health"]) + } + } + } + if store < 0 || program < 0 || store > program { + t.Errorf("store at %d, program at %d: want the store listed first", store, program) + } + // Running review F2: incoming asks the bus 8 GiB, the most the objects template allows, equal to IncomingCap + // (said only where no binding is read); transfers keeps for good, the holder dropping an idle one by its key + // in idle, a state of keys with their own time to live. + var req struct { + Requires []struct { + Name string `json:"name"` + Options map[string]any `json:"options"` + } `json:"requires"` + } + _ = json.Unmarshal(raw, &req) + opts := map[string]map[string]any{} + for _, r := range req.Requires { + opts[r.Name] = r.Options + } + if mib, _ := opts[IncomingObjects]["max-mib"].(float64); uint64(mib)<<20 != IncomingCap { + t.Errorf("incoming asks %v MiB, IncomingCap is %d bytes: want them equal", opts[IncomingObjects]["max-mib"], uint64(IncomingCap)) + } + if _, ok := opts[TransfersState]["ttl-seconds"]; ok { + t.Errorf("transfers has a lifetime %v: the bus would drop a transfer's key and leave its parts", opts[TransfersState]) + } + if opts[IdleState]["key-ttl"] != true { + t.Errorf("idle's options %v: want key-ttl, each transfer's key ending on its own", opts[IdleState]) + } + }) +} + +// registryForms reads a reference of the registry form, with the two seats' names this holder answers for. +var registryForms = artifactref.Seats{Image: ImageSeat, Object: ArtifactSeat} diff --git a/modules/distribution/internal/holder/list.go b/modules/distribution/internal/holder/list.go new file mode 100644 index 000000000..9361c35df --- /dev/null +++ b/modules/distribution/internal/holder/list.go @@ -0,0 +1,109 @@ +package holder + +// **`list`: what the store holds, through the seat** (novox/hq ADR 0632 §3). For one repository, or every repository a +// seat holds, each manifest's digest, whether it is an index and the manifests it names, the tags naming it, its size +// and whether a hold keeps it. Read from the store's own files (files.go), so a manifest no tag names is listed too. The +// two seats share one store and split it by what a manifest keeps: a manifest keeping one blob under an empty +// configuration is a bundle or an archive, oci-artifact-registry's, named by that blob as its references name it; +// every other manifest, an index included, is container-image-registry's. + +import ( + "context" + "errors" + "fmt" + "sort" + + reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" +) + +// VerbList is the seats' verb list. +const VerbList = reg.VerbList + +// StoreFiles is the store's own files as list reads them: Files in the program, a test's stand-in. +type StoreFiles interface { + List(ctx context.Context) (*Listing, error) + Manifests(ctx context.Context, l *Listing) (map[string]*Manifest, map[string]string) +} + +// List is `list` on seat: the repositories it holds, or name alone; a repository the seat holds nothing of is not named, +// and name not held is an empty answer. A manifest whose content cannot be read fails the answer, by name: a listing +// missing one would read as the store not holding it. +func (v *Verbs) List(ctx context.Context, seat, caller, name string) ([]reg.Repository, error) { + if err := v.Callers.Allowed(VerbList, caller); err != nil { + return nil, err + } + if seat != ImageSeat && seat != ArtifactSeat { + return nil, fmt.Errorf("list: %s is no seat of distribution's", seat) + } + if v.Files == nil { + return nil, errors.New("list: this process reads no store files") + } + l, err := v.Files.List(ctx) + if err != nil { + return nil, fmt.Errorf("list: the store's files cannot be listed: %w", err) + } + if name != "" { + r, held := l.Repos[name] + l = &Listing{Blobs: l.Blobs, Repos: map[string]*Repo{}} + if held { + l.Repos[name] = r + } + } + docs, unread := v.Files.Manifests(ctx, l) + if len(unread) > 0 { + keys := make([]string, 0, len(unread)) + for k := range unread { + keys = append(keys, k) + } + sort.Strings(keys) + return nil, fmt.Errorf("list: %d manifests cannot be read, the first %s: %s", len(keys), keys[0], unread[keys[0]]) + } + holds, err := v.Holds.List() + if err != nil { + return nil, fmt.Errorf("list: the holds cannot be read: %w", err) + } + held := map[string]bool{} + for _, h := range holds { + held[h.Key()] = true + } + out := []reg.Repository{} + for _, repo := range l.RepoNames() { + r := l.Repos[repo] + tagsAt := map[string][]string{} + for t, d := range r.Tags { + tagsAt[d] = append(tagsAt[d], t) + } + digests := make([]string, 0, len(r.Revisions)) + for d := range r.Revisions { + digests = append(digests, d) + } + sort.Strings(digests) + var manifests []reg.Listed + for _, d := range digests { + m := docs[Key(repo, d)] + artifact := "" + if m.Holder() { + artifact = m.Layers[0] + } + if (artifact != "") != (seat == ArtifactSeat) { + continue + } + e := reg.Listed{Digest: d, Index: len(m.Children) > 0, Platforms: m.Children, Artifact: artifact, + Tags: tagsAt[d], Size: l.Blobs[d]} + for _, b := range m.Refs { + e.Size += l.Blobs[b] + } + sort.Strings(e.Tags) + by := d + if artifact != "" { + by = artifact // a bundle or an archive is held by the blob its references name (HoldOne) + } + e.Held = held[Hold{Seat: seat, Name: repo, Digest: by}.Key()] + manifests = append(manifests, e) + } + if len(manifests) > 0 { + out = append(out, reg.Repository{Name: repo, Manifests: manifests}) + } + } + return out, nil +} diff --git a/modules/distribution/internal/holder/list_test.go b/modules/distribution/internal/holder/list_test.go new file mode 100644 index 000000000..a29eaf391 --- /dev/null +++ b/modules/distribution/internal/holder/list_test.go @@ -0,0 +1,226 @@ +package holder + +import ( + "context" + "fmt" + "reflect" + "strings" + "testing" + + reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" +) + +// dg is a digest made from a short name, so a test reads as names. +func dg(name string) string { return fmt.Sprintf("sha256:%064x", []byte(name))[:71] } + +// fakeFiles is a store's files and its manifests: what listScript and readScript print through its container. +type fakeFiles struct { + blobs map[string]int64 + revisions map[string][]string // repo -> digests + tags map[string]map[string]string + manifests map[string]string // digest -> content +} + +func (f *fakeFiles) listing() string { + var b strings.Builder + b.WriteString("#blobs\n") + for d, n := range f.blobs { + h := strings.TrimPrefix(d, "sha256:") + fmt.Fprintf(&b, "%d blobs/sha256/%s/%s/data\n", n, h[:2], h) + } + b.WriteString("#revisions\n") + for repo, ds := range f.revisions { + for _, d := range ds { + fmt.Fprintf(&b, "repositories/%s/_manifests/revisions/sha256/%s/link\n", repo, strings.TrimPrefix(d, "sha256:")) + } + } + b.WriteString("#tags\n") + for repo, ts := range f.tags { + for t, d := range ts { + fmt.Fprintf(&b, "repositories/%s/_manifests/tags/%s/current/link:%s\n", repo, t, d) + } + } + b.WriteString("#end\n") + return b.String() +} + +// run is the store's container as docker exec reaches it; anything else fails the test: list only reads. +func (f *fakeFiles) run(t *testing.T) Runner { + return func(_ context.Context, name string, args ...string) Ran { + if name != "docker" || len(args) < 5 || args[0] != "exec" || args[1] != "mesh-registry" || args[3] != "-c" { + t.Fatalf("ran %s %v", name, args) + } + switch args[4] { + case listScript: + return Ran{Stdout: f.listing()} + case readScript: + var b strings.Builder + for _, p := range args[6:] { + fmt.Fprintf(&b, "#manifest %s\n", p) + found := false + for d, body := range f.manifests { + if blobPath(d) == p { + b.WriteString(body + "\n") + found = true + } + } + if !found { + b.WriteString("#absent\n") + } + } + b.WriteString("#end\n") + return Ran{Stdout: b.String()} + } + t.Fatalf("ran a script list does not have: %q", args[4]) + return Ran{} + } +} + +func imageDoc(config string, layers ...string) string { + var ls []string + for _, l := range layers { + ls = append(ls, fmt.Sprintf(`{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":%q,"size":1}`, l)) + } + return fmt.Sprintf(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":%q,"size":1},"layers":[%s]}`, + config, strings.Join(ls, ",")) +} + +func holderDoc(blob string) string { + return fmt.Sprintf(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.oci.empty.v1+json","digest":%q,"size":2},"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":%q,"size":1}]}`, + dg("empty"), blob) +} + +func indexDoc(children ...string) string { + var cs []string + for _, c := range children { + cs = append(cs, fmt.Sprintf(`{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":%q,"size":1}`, c)) + } + return fmt.Sprintf(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[%s]}`, strings.Join(cs, ",")) +} + +// filesWorld is one store: app/server an image, an untagged one and an index naming the first; app/tools a bundle +// keeping a-kept; mesh/facts a document under the tag latest. +func filesWorld() *fakeFiles { + f := &fakeFiles{blobs: map[string]int64{}, revisions: map[string][]string{}, tags: map[string]map[string]string{}, + manifests: map[string]string{}} + put := func(repo, name, content string) { + f.manifests[dg(name)] = content + f.blobs[dg(name)] = int64(len(content)) + f.revisions[repo] = append(f.revisions[repo], dg(name)) + } + for name, size := range map[string]int64{"cfg": 10, "layer": 1000, "a-kept": 2000, "empty": 2, "doc-body": 50} { + f.blobs[dg(name)] = size + } + put("app/server", "m-kept", imageDoc(dg("cfg"), dg("layer"))) + put("app/server", "m-old", imageDoc(dg("cfg"))) + put("app/server", "idx", indexDoc(dg("m-kept"))) + put("app/tools", "h-kept", holderDoc(dg("a-kept"))) + put("mesh/facts", "doc", holderDoc(dg("doc-body"))) + f.tags["app/server"] = map[string]string{"3": dg("idx")} + f.tags["mesh/facts"] = map[string]string{"latest": dg("doc")} + return f +} + +// list (ADR 0632 §3): each seat answers its part of the one store from the store's own files, untagged manifests +// included; an index with the manifests it names; a bundle or archive by the blob its references name, held as a hold +// keeps it; and the files' listing read whole or refused. +func TestListAnswersTheStoreThroughTheSeat(t *testing.T) { + f := filesWorld() + b := newBed(true) + b.v.Files = &Files{Container: "mesh-registry", Run: f.run(t)} + if err := b.v.HoldOne(ArtifactSeat, SweepHolder, HoldRequest{Name: "app/tools", Digest: dg("a-kept"), Why: "kept"}); err != nil { + t.Fatal(err) + } + ctx := context.Background() + images, err := b.v.List(ctx, ImageSeat, "mesh-anyone", "") + if err != nil { + t.Fatal(err) + } + size := func(name string, blobs ...string) int64 { + n := f.blobs[dg(name)] + for _, b := range blobs { + n += f.blobs[dg(b)] + } + return n + } + want := []reg.Repository{{Name: "app/server", Manifests: []reg.Listed{}}} + for _, e := range []reg.Listed{ + {Digest: dg("idx"), Index: true, Platforms: []string{dg("m-kept")}, Tags: []string{"3"}, Size: size("idx")}, + {Digest: dg("m-kept"), Size: size("m-kept", "cfg", "layer")}, + {Digest: dg("m-old"), Size: size("m-old", "cfg")}, + } { + want[0].Manifests = append(want[0].Manifests, e) + } + sortListed(want[0].Manifests) + if !reflect.DeepEqual(images, want) { + t.Errorf("the image seat lists %+v\nwant %+v", images, want) + } + artifacts, err := b.v.List(ctx, ArtifactSeat, "mesh-anyone", "") + if err != nil { + t.Fatal(err) + } + wantA := []reg.Repository{ + {Name: "app/tools", Manifests: []reg.Listed{{Digest: dg("h-kept"), Artifact: dg("a-kept"), Size: size("h-kept", "empty", "a-kept"), Held: true}}}, + {Name: "mesh/facts", Manifests: []reg.Listed{{Digest: dg("doc"), Artifact: dg("doc-body"), Tags: []string{"latest"}, Size: size("doc", "empty", "doc-body")}}}, + } + if !reflect.DeepEqual(artifacts, wantA) { + t.Errorf("the artifact seat lists %+v\nwant %+v", artifacts, wantA) + } + if one, err := b.v.List(ctx, ImageSeat, "mesh-anyone", "app/tools"); err != nil || len(one) != 0 { + t.Errorf("the image seat's list of a bundle's repository: %+v, %v", one, err) + } + if one, err := b.v.List(ctx, ArtifactSeat, "mesh-anyone", "mesh/facts"); err != nil || len(one) != 1 || one[0].Name != "mesh/facts" { + t.Errorf("one repository: %+v, %v", one, err) + } + // A manifest whose content cannot be read fails the answer: a listing without it would read as the store not holding it. + delete(f.manifests, dg("m-old")) + b.v.Files = &Files{Container: "mesh-registry", Run: f.run(t)} // what was read is kept: a fresh reader reads again + if _, err := b.v.List(ctx, ImageSeat, "mesh-anyone", ""); err == nil || !strings.Contains(err.Error(), "cannot be read") { + t.Errorf("an unreadable manifest: %v", err) + } + // The files' listing: a repository with slashes read, a cut or stray one refused. + l, err := ParseListing("#blobs\n12 blobs/sha256/ab/abcd/data\n#revisions\nrepositories/a/b/c/_manifests/revisions/sha256/ff/link\n#tags\nrepositories/a/b/c/_manifests/tags/v1/current/link:sha256:ff\n#end\n") + if err != nil || l.Blobs["sha256:abcd"] != 12 || !l.Repos["a/b/c"].Revisions["sha256:ff"] || l.Repos["a/b/c"].Tags["v1"] != "sha256:ff" { + t.Errorf("the listing misread: %+v, %v", l, err) + } + for _, out := range []string{"#blobs\n12 blobs/sha256/ab/abcd/data\n", "#blobs\nnot a line\n#end\n", "stray\n#end\n"} { + if _, err := ParseListing(out); err == nil { + t.Errorf("accepted %q", out) + } + } + got, err := parseManifests("#manifest blobs/sha256/aa/aa1/data\n"+imageDoc(dg("cfg"), dg("l"))+"\n\n#manifest blobs/sha256/bb/bb1/data\n#absent\n#manifest blobs/sha256/cc/cc1/data\nnot json\n#end\n", + map[string]string{"blobs/sha256/aa/aa1/data": "sha256:aa1", "blobs/sha256/bb/bb1/data": "sha256:bb1", "blobs/sha256/cc/cc1/data": "sha256:cc1"}) + if err != nil || got["sha256:aa1"] == nil || len(got["sha256:aa1"].Refs) != 2 || got["sha256:bb1"] != nil || got["sha256:cc1"] != nil { + t.Errorf("manifests read %v, %v", got, err) + } + if _, err := parseManifests("#manifest x\n{}\n", map[string]string{"x": "sha256:x"}); err == nil { + t.Error("a cut read was accepted") + } + // The runtime's socket refusing the account is asked again through sudo -n; a container not there is said so. + var ran []string + sudo := func(_ context.Context, name string, args ...string) Ran { + ran = append(ran, name) + if name == "docker" { + return Ran{Status: 1, Stderr: "permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock"} + } + return Ran{Stdout: "#end\n"} + } + if _, err := docker(ctx, sudo, 1000, "exec", "x"); err != nil || !reflect.DeepEqual(ran, []string{"docker", "sudo"}) { + t.Errorf("ran %v: %v", ran, err) + } + if _, err := docker(ctx, func(context.Context, string, ...string) Ran { + return Ran{Status: 1, Stderr: "Error: No such container: mesh-registry"} + }, 1000, "exec"); err == nil || !strings.Contains(err.Error(), "not on this machine") { + t.Errorf("err %v", err) + } +} + +func sortListed(l []reg.Listed) { + for i := range l { + for j := i + 1; j < len(l); j++ { + if l[j].Digest < l[i].Digest { + l[i], l[j] = l[j], l[i] + } + } + } +} diff --git a/modules/distribution/internal/holder/mirror.go b/modules/distribution/internal/holder/mirror.go new file mode 100644 index 000000000..1e35f6da0 --- /dev/null +++ b/modules/distribution/internal/holder/mirror.go @@ -0,0 +1,303 @@ +package holder + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" +) + +// Upstream reads an outside registry by the OCI distribution protocol, anonymously: on a 401 it takes the bearer +// token the registry's WWW-Authenticate names, scoped to a pull of the one repository, and asks again (the form +// Docker Hub, ghcr.io and quay.io answer). Scheme is "https" but in tests. +type Upstream struct { + Client *http.Client + Scheme string +} + +func (u *Upstream) client() *http.Client { + if u != nil && u.Client != nil { + return u.Client + } + return http.DefaultClient +} + +func (u *Upstream) scheme() string { + if u != nil && u.Scheme != "" { + return u.Scheme + } + return "https" +} + +// accepted are the manifest forms a mirror copies. +var accepted = []string{ + "application/vnd.oci.image.index.v1+json", mediaManifest, + "application/vnd.docker.distribution.manifest.list.v2+json", "application/vnd.docker.distribution.manifest.v2+json", +} + +// Source is an outside image's place: its registry host (as reached) and its repository path, from `/` +// with a tag or digest dropped. A name with no host is Docker Hub's, and a one-part path there is under library/. +type Source struct { + Host, Path string +} + +// ParseSource reads an outside image's name. +func ParseSource(s string) (Source, error) { + s = strings.TrimSpace(s) + if i := strings.Index(s, "@"); i >= 0 { + s = s[:i] + } + if i := strings.LastIndex(s, ":"); i > strings.LastIndex(s, "/") { + s = s[:i] + } + if s == "" || strings.Contains(s, "..") { + return Source{}, fmt.Errorf("mirror: %q is not an image's name", s) + } + host, path, ok := strings.Cut(s, "/") + if !ok || !(strings.ContainsAny(host, ".:") || host == "localhost") { + host, path = "docker.io", s + } + if host == "docker.io" && !strings.Contains(path, "/") { + path = "library/" + path + } + return Source{Host: host, Path: path}, nil +} + +// Name is the copy's name in the store, `upstream//` (design 51). +func (s Source) Name() string { return "upstream/" + s.Host + "/" + s.Path } + +func (s Source) reach() string { + if s.Host == "docker.io" { + return "registry-1.docker.io" + } + return s.Host +} + +// get asks the upstream for path, taking an anonymous token once when the upstream asks for one. +func (u *Upstream) get(src Source, path string, accept []string) (*http.Response, error) { + target := u.scheme() + "://" + src.reach() + "/v2/" + src.Path + path + token := "" + for try := 0; try < 2; try++ { + req, err := http.NewRequest(http.MethodGet, target, nil) + if err != nil { + return nil, err + } + for _, a := range accept { + req.Header.Add("Accept", a) + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := u.client().Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusUnauthorized || token != "" { + return resp, nil + } + challenge := resp.Header.Get("WWW-Authenticate") + resp.Body.Close() + if token, err = u.token(challenge, src); err != nil { + return nil, err + } + } + return nil, fmt.Errorf("mirror: %s refused the anonymous token", src.reach()) +} + +// token takes an anonymous pull token from a Bearer challenge. +func (u *Upstream) token(challenge string, src Source) (string, error) { + rest, ok := strings.CutPrefix(challenge, "Bearer ") + if !ok { + return "", fmt.Errorf("mirror: %s asks a credential the mesh does not hold (%q); only anonymous reads are copied", src.reach(), challenge) + } + params := map[string]string{} + for _, kv := range strings.Split(rest, ",") { + k, v, _ := strings.Cut(strings.TrimSpace(kv), "=") + params[k] = strings.Trim(v, `"`) + } + realm, err := url.Parse(params["realm"]) + if err != nil || params["realm"] == "" { + return "", fmt.Errorf("mirror: %s names no token realm", src.reach()) + } + q := realm.Query() + if params["service"] != "" { + q.Set("service", params["service"]) + } + q.Set("scope", "repository:"+src.Path+":pull") + realm.RawQuery = q.Encode() + resp, err := u.client().Get(realm.String()) + if err != nil { + return "", err + } + defer resp.Body.Close() + var t struct { + Token string `json:"token"` + AccessToken string `json:"access_token"` + } + if resp.StatusCode != http.StatusOK || json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&t) != nil { + return "", fmt.Errorf("mirror: %s gave no anonymous token (%d)", realm.Host, resp.StatusCode) + } + if t.Token == "" { + t.Token = t.AccessToken + } + return t.Token, nil +} + +// manifest reads one manifest by its digest and checks its bytes against it. +func (u *Upstream) manifest(src Source, digest string) ([]byte, string, error) { + resp, err := u.get(src, "/manifests/"+digest, accepted) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, "", fmt.Errorf("mirror: %s/%s@%s answered %d", src.Host, src.Path, digest, resp.StatusCode) + } + b, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) + if err != nil { + return nil, "", err + } + if "sha256:"+sum(b) != digest { + return nil, "", fmt.Errorf("mirror: the manifest %s answered does not hash to its digest", digest) + } + mt := resp.Header.Get("Content-Type") + var probe struct { + MediaType string `json:"mediaType"` + } + if json.Unmarshal(b, &probe) == nil && probe.MediaType != "" { + mt = probe.MediaType + } + return b, mt, nil +} + +// copyBlob streams one blob from the upstream into the store, its digest checked as it passes; a blob held is +// not fetched. +func (o *OCI) copyBlob(up *Upstream, src Source, name string, desc descriptor) error { + if has, err := o.Has(name, desc.Digest); err != nil { + return err + } else if has { + return nil + } + resp, err := up.get(src, "/blobs/"+desc.Digest, nil) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("mirror: the blob %s answered %d", desc.Digest, resp.StatusCode) + } + h := sha256.New() + body := io.TeeReader(io.LimitReader(resp.Body, desc.Size+1), h) + if err := o.putBlobStream(name, desc.Digest, desc.Size, body); err != nil { + return err + } + if got := "sha256:" + hex.EncodeToString(h.Sum(nil)); got != desc.Digest { + return fmt.Errorf("mirror: the blob %s answered hashes to %s", desc.Digest, got) + } + return nil +} + +// putBlobStream stores a blob of a known size from a reader, monolithically. +func (o *OCI) putBlobStream(name, digest string, size int64, body io.Reader) error { + resp, b, err := o.do(http.MethodPost, "/v2/"+name+"/blobs/uploads/", "", nil) + if err != nil { + return err + } + if resp.StatusCode != http.StatusAccepted { + return fmt.Errorf("the store did not open an upload of %s (%d): %s", digest, resp.StatusCode, b) + } + loc, err := url.Parse(resp.Header.Get("Location")) + if err != nil { + return err + } + q := loc.Query() + q.Set("digest", digest) + loc.RawQuery = q.Encode() + req, err := http.NewRequest(http.MethodPut, o.url(loc.Path+"?"+loc.RawQuery), body) + if err != nil { + return err + } + req.ContentLength = size + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("Content-Length", strconv.FormatInt(size, 10)) + put, err := o.client().Do(req) + if err != nil { + return err + } + defer put.Body.Close() + if put.StatusCode != http.StatusCreated { + b, _ := io.ReadAll(io.LimitReader(put.Body, 4096)) + return fmt.Errorf("the store did not take %s (%d): %s", digest, put.StatusCode, b) + } + return nil +} + +type descriptor struct { + MediaType string `json:"mediaType"` + Digest string `json:"digest"` + Size int64 `json:"size"` +} + +// copyManifest copies one manifest and all it names: an index's every child first (the store refuses an index whose +// children it lacks), an image's config and layers; then the manifest itself under its digest. +func (o *OCI) copyManifest(up *Upstream, src Source, name, digest string, depth int) error { + if depth > 2 { + return fmt.Errorf("mirror: %s nests indexes deeper than an image does", digest) + } + if d, err := o.Digest(name, digest); err == nil && d == digest { + return nil // held whole already + } + b, mt, err := up.manifest(src, digest) + if err != nil { + return err + } + var m struct { + Manifests []descriptor `json:"manifests"` + Config descriptor `json:"config"` + Layers []descriptor `json:"layers"` + } + if err := json.Unmarshal(b, &m); err != nil { + return fmt.Errorf("mirror: the manifest %s does not read: %w", digest, err) + } + for _, c := range m.Manifests { + if err := o.copyManifest(up, src, name, c.Digest, depth+1); err != nil { + return err + } + } + if m.Config.Digest != "" { + if err := o.copyBlob(up, src, name, m.Config); err != nil { + return err + } + } + for _, l := range m.Layers { + if err := o.copyBlob(up, src, name, l); err != nil { + return err + } + } + _, err = o.putManifest(name, digest, mt, b) + return err +} + +// Mirror copies an outside image, pinned by its digest, into the store as `upstream//` (design 51: an +// upstream image is copied by `mirror` with its pinned digest), every platform of an index included, only the parts +// the store lacks; it answers the copy's reference. A copy held already is answered at once. +func (o *OCI) Mirror(source, digest string) (string, error) { + src, err := ParseSource(source) + if err != nil { + return "", err + } + name := src.Name() + if err := o.copyManifest(o.Upstream, src, name, digest, 0); err != nil { + return "", err + } + return o.Reference(name, digest), nil +} + +// Reference is the path of the seat's answer for name at digest: `@`, the seat's name put before it by +// the holder (SeatReference). +func (o *OCI) Reference(name, digest string) string { return name + "@" + digest } diff --git a/modules/distribution/internal/holder/mirror_fakes_test.go b/modules/distribution/internal/holder/mirror_fakes_test.go new file mode 100644 index 000000000..d65f18d2e --- /dev/null +++ b/modules/distribution/internal/holder/mirror_fakes_test.go @@ -0,0 +1,151 @@ +package holder + +import ( + "errors" + "io" + "net/http" + "strconv" + "strings" + "sync" +) + +// fakeRegistry is an OCI registry in memory, enough for a mirror's copy: blobs, uploads and manifests by +// reference. With token set it answers 401 with a Bearer challenge until a request carries it. A DELETE of a manifest +// answers as registry 2.8.3, the store's pinned image, does (distribution v2.8.3 registry/handlers/manifests.go, +// DeleteManifest): by digest it deletes the manifest and every tag naming it; by a tag it deletes nothing and answers +// 400 DIGEST_INVALID, the check of the empty digest it deletes instead failing. +type fakeRegistry struct { + mu sync.Mutex + blobs map[string][]byte // name@digest + manifests map[string][]byte // name:ref + types map[string]string + token string + realm string + gets []string +} + +func newFakeRegistry() *fakeRegistry { + return &fakeRegistry{blobs: map[string][]byte{}, manifests: map[string][]byte{}, types: map[string]string{}} +} + +func (f *fakeRegistry) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + if r.URL.Path == "/token" { + _, _ = io.WriteString(w, `{"token":"`+f.token+`"}`) + return + } + if f.token != "" && r.Header.Get("Authorization") != "Bearer "+f.token { + w.Header().Set("WWW-Authenticate", `Bearer realm="`+f.realm+`",service="fake"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + p := strings.TrimPrefix(r.URL.Path, "/v2/") + switch { + case strings.Contains(p, "/blobs/uploads/"): + name := p[:strings.Index(p, "/blobs/uploads/")] + if r.Method == http.MethodPost { + w.Header().Set("Location", "/v2/"+name+"/blobs/uploads/u1") + w.WriteHeader(http.StatusAccepted) + return + } + b, _ := io.ReadAll(r.Body) + d := r.URL.Query().Get("digest") + if digestOf(b) != d { + w.WriteHeader(http.StatusBadRequest) + return + } + f.blobs[name+"@"+d] = b + w.WriteHeader(http.StatusCreated) + case strings.Contains(p, "/blobs/"): + i := strings.Index(p, "/blobs/") + b, ok := f.blobs[p[:i]+"@"+p[i+7:]] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + if r.Method == http.MethodGet { + f.gets = append(f.gets, p[i+7:]) + } + w.Header().Set("Content-Length", strconv.Itoa(len(b))) // a HEAD's too, as a registry answers it + _, _ = w.Write(b) + case strings.Contains(p, "/manifests/"): + i := strings.Index(p, "/manifests/") + key := p[:i] + ":" + p[i+11:] + if r.Method == http.MethodDelete { + ref := p[i+11:] + if !strings.HasPrefix(ref, "sha256:") { + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, `{"errors":[{"code":"DIGEST_INVALID","message":"provided digest did not match uploaded content"}]}`) + return + } + found := false + for k, b := range f.manifests { + if strings.HasPrefix(k, p[:i]+":") && digestOf(b) == ref { + delete(f.manifests, k) + found = true + } + } + if !found { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusAccepted) + return + } + if r.Method == http.MethodPut { + b, _ := io.ReadAll(r.Body) + f.manifests[key] = b + f.types[key] = r.Header.Get("Content-Type") + w.WriteHeader(http.StatusCreated) + return + } + b, ok := f.manifests[key] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Set("Content-Type", f.types[key]) + w.Header().Set("Docker-Content-Digest", digestOf(b)) + _, _ = w.Write(b) + default: + w.WriteHeader(http.StatusNotFound) + } +} + +// fakeDelivery answers the driver's reads: haves, and per node whether its node-engine serves manifest-reads. +type fakeDelivery struct { + haves []NodeHave + stated map[string][]string // a node absent does not serve manifest-reads +} + +func (f *fakeDelivery) Haves() ([]NodeHave, error) { return f.haves, nil } +func (f *fakeDelivery) Reads(node string) (bool, []string, error) { + s, ok := f.stated[node] + if !ok { + return false, nil, errors.New("nats: timeout") + } + return true, s, nil +} + +type memWindows struct { + w map[string]Window + pub Publishing +} + +func (m *memWindows) Window(n string) (Window, error) { return m.w[n], nil } +func (m *memWindows) PutWindow(w Window) error { m.w[w.Name] = w; return nil } +func (m *memWindows) Publishing() (Publishing, error) { return m.pub, nil } + +// downCatalog is a store whose read side does not answer, while down is set. +type downCatalog struct { + fakeCatalog + down bool +} + +func (d *downCatalog) Versions(name string) ([]string, error) { + if d.down { + return nil, errors.New("connection refused") + } + return d.fakeCatalog.Versions(name) +} diff --git a/modules/distribution/internal/holder/oci.go b/modules/distribution/internal/holder/oci.go new file mode 100644 index 000000000..7b41d9feb --- /dev/null +++ b/modules/distribution/internal/holder/oci.go @@ -0,0 +1,298 @@ +package holder + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" +) + +// OCI is the artifact store as the OCI distribution protocol answers it on its node's loopback, written by +// distribution's own process alone: the Store and the Catalog the verbs use. It answers a reference's path, +// `@sha256:`; the holder puts the seat's name before it (SeatReference, ADR 0605 §9). +type OCI struct { + Base *url.URL + Client *http.Client + // Upstream reads the outside registries `mirror` copies from. + Upstream *Upstream +} + +const ( + mediaManifest = "application/vnd.oci.image.manifest.v1+json" + mediaEmptyConfig = "application/vnd.oci.empty.v1+json" + mediaLayer = "application/vnd.oci.image.layer.v1.tar" + emptyConfig = "{}" +) + +func (o *OCI) client() *http.Client { + if o.Client != nil { + return o.Client + } + return http.DefaultClient +} + +func (o *OCI) url(path string) string { return strings.TrimSuffix(o.Base.String(), "/") + path } + +func (o *OCI) do(method, path, contentType string, body []byte, accept ...string) (*http.Response, []byte, error) { + req, err := http.NewRequest(method, o.url(path), bytes.NewReader(body)) + if err != nil { + return nil, nil, err + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + for _, a := range accept { + req.Header.Add("Accept", a) + } + resp, err := o.client().Do(req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + b, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<20)) + return resp, b, nil +} + +// Has is whether name holds a blob. +func (o *OCI) Has(name, digest string) (bool, error) { + resp, _, err := o.do(http.MethodHead, "/v2/"+name+"/blobs/"+digest, "", nil) + if err != nil { + return false, err + } + return resp.StatusCode == http.StatusOK, nil +} + +func (o *OCI) putBlob(name, digest string, data []byte) error { + resp, b, err := o.do(http.MethodPost, "/v2/"+name+"/blobs/uploads/", "", nil) + if err != nil { + return err + } + if resp.StatusCode != http.StatusAccepted { + return fmt.Errorf("the store did not open an upload of %s (%d): %s", digest, resp.StatusCode, b) + } + loc, err := url.Parse(resp.Header.Get("Location")) + if err != nil { + return err + } + q := loc.Query() + q.Set("digest", digest) + loc.RawQuery = q.Encode() + path := loc.Path + if loc.RawQuery != "" { + path += "?" + loc.RawQuery + } + resp, b, err = o.do(http.MethodPut, path, "application/octet-stream", data) + if err != nil { + return err + } + if resp.StatusCode != http.StatusCreated { + return fmt.Errorf("the store did not take %s (%d): %s", digest, resp.StatusCode, b) + } + return nil +} + +func (o *OCI) putManifest(name, ref, mediaType string, data []byte) (string, error) { + resp, b, err := o.do(http.MethodPut, "/v2/"+name+"/manifests/"+ref, mediaType, data) + if err != nil { + return "", err + } + if resp.StatusCode != http.StatusCreated { + return "", fmt.Errorf("the store did not take the manifest %s:%s (%d): %s", name, ref, resp.StatusCode, b) + } + return "sha256:" + sum(data), nil +} + +// Put stores a transfer: an image's layers and config as blobs and its manifest (the part whose media type is a +// manifest's) under its version; a bundle's or an archive's one blob under an artifact manifest of an empty config and +// one layer (the shape the artifact sweep recognises), under its version, or by its digest alone when unnumbered. +func (o *OCI) Put(t PublishRequest, parts map[string][]byte) (string, error) { + var manifest *Part + digests := make([]string, 0, len(parts)) + for d := range parts { + digests = append(digests, d) + } + sort.Strings(digests) + for i, p := range t.Parts { + // An image's manifest is the part whose digest is the publish's (the seat's contract names no media type); a + // part naming a manifest's media type is one too. + if (t.Kind == KindImage || t.Kind == KindUpstream) && (p.Digest == t.Digest || strings.Contains(p.MediaType, "manifest")) { + manifest = &t.Parts[i] + continue + } + if data, ok := parts[p.Digest]; ok { + if err := o.putBlob(t.Name, p.Digest, data); err != nil { + return "", err + } + } + } + ref := t.Version + switch t.Kind { + case KindImage, KindUpstream: + if manifest == nil { + return "", fmt.Errorf("an image's publish names no manifest part") + } + data, ok := parts[manifest.Digest] + if !ok { + // Held already: fetch it to tag it under the version. + resp, b, err := o.do(http.MethodGet, "/v2/"+t.Name+"/manifests/"+manifest.Digest, "", nil, accepted...) + if err != nil || resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("the held manifest %s cannot be read: %v", manifest.Digest, err) + } + data = b + } + if ref == "" { + ref = manifest.Digest + } + mt := manifest.MediaType + var probe struct { + MediaType string `json:"mediaType"` + } + if mt == "" && json.Unmarshal(data, &probe) == nil { + mt = probe.MediaType + } + if mt == "" { + mt = mediaManifest + } + d, err := o.putManifest(t.Name, ref, mt, data) + if err != nil { + return "", err + } + return t.Name + "@" + d, nil + default: + if err := o.putBlob(t.Name, "sha256:"+sum([]byte(emptyConfig)), []byte(emptyConfig)); err != nil { + return "", err + } + size, err := o.layerSize(t, parts) + if err != nil { + return "", err + } + m, _ := json.Marshal(map[string]any{ + "schemaVersion": 2, "mediaType": mediaManifest, + "config": map[string]any{"mediaType": mediaEmptyConfig, "digest": "sha256:" + sum([]byte(emptyConfig)), "size": len(emptyConfig)}, + "layers": []map[string]any{{"mediaType": mediaLayer, "digest": t.Digest, "size": size}}, + }) + if ref == "" { + ref = "sha256:" + sum(m) + } + if _, err := o.putManifest(t.Name, ref, mediaManifest, m); err != nil { + return "", err + } + return t.Name + "@" + t.Digest, nil + } +} + +// layerSize is the size of a bundle's or an archive's one blob: its bytes' when they travelled, else the size its part +// names, else (a late publish of a held digest names it with no size, its bytes having left the build node) the store's. +func (o *OCI) layerSize(t PublishRequest, parts map[string][]byte) (int64, error) { + if data, ok := parts[t.Digest]; ok { + return int64(len(data)), nil + } + for _, p := range t.Parts { + if p.Digest == t.Digest && p.Size > 0 { + return p.Size, nil + } + } + resp, _, err := o.do(http.MethodHead, "/v2/"+t.Name+"/blobs/"+t.Digest, "", nil) + if err != nil { + return 0, err + } + if resp.StatusCode != http.StatusOK || resp.ContentLength < 0 { + return 0, fmt.Errorf("the store holds no blob %s of %s to tag (%d)", t.Digest, t.Name, resp.StatusCode) + } + return resp.ContentLength, nil +} + +// Held is the reference of name at version when the store holds digest under it. +func (o *OCI) Held(name, version, digest string) (string, bool, error) { + d, err := o.Digest(name, version) + if err != nil || d == "" { + return "", false, err + } + if d != digest { + return "", false, nil + } + return name + "@" + d, true, nil +} + +// Versions are name's tags. +func (o *OCI) Versions(name string) ([]string, error) { + resp, b, err := o.do(http.MethodGet, "/v2/"+name+"/tags/list", "", nil) + if err != nil { + return nil, err + } + if resp.StatusCode == http.StatusNotFound { + return []string{}, nil + } + var out struct { + Tags []string `json:"tags"` + } + if resp.StatusCode != http.StatusOK || json.Unmarshal(b, &out) != nil { + return nil, fmt.Errorf("the store's tags of %s (%d): %s", name, resp.StatusCode, b) + } + sort.Strings(out.Tags) + return out.Tags, nil +} + +// Digest is the manifest digest name holds at version, empty when none. +func (o *OCI) Digest(name, version string) (string, error) { + resp, _, err := o.do(http.MethodHead, "/v2/"+name+"/manifests/"+version, "", nil, mediaManifest, + "application/vnd.oci.image.index.v1+json", "application/vnd.docker.distribution.manifest.v2+json") + if err != nil { + return "", err + } + if resp.StatusCode == http.StatusNotFound { + return "", nil + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("the store answered %d for %s:%s", resp.StatusCode, name, version) + } + return resp.Header.Get("Docker-Content-Digest"), nil +} + +// ErrNoTagAlone is a store that cannot delete a tag alone. registry 2.8.3, the store's pinned image, is one: its +// DeleteManifest (distribution v2.8.3 registry/handlers/manifests.go) ignores a tag reference and deletes the empty +// digest, whose check fails, answered 400 DIGEST_INVALID; registry 3 untags alone. +var ErrNoTagAlone = errors.New("the store cannot remove a tag alone") + +// RemoveTag deletes the tag version of name alone (the OCI distribution protocol's DELETE of a manifest by its tag); +// the manifest stays under every other tag. A store that cannot delete a tag answers ErrNoTagAlone, any other failure +// an error of its own; either removes nothing. +func (o *OCI) RemoveTag(name, version string) error { + resp, b, err := o.do(http.MethodDelete, "/v2/"+name+"/manifests/"+version, "", nil) + if err != nil { + return err + } + if resp.StatusCode == http.StatusBadRequest && bytes.Contains(b, []byte(`"DIGEST_INVALID"`)) { + return fmt.Errorf("%w: DELETE of %s:%s by its tag answered 400 DIGEST_INVALID", ErrNoTagAlone, name, version) + } + if resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusNotFound { + return fmt.Errorf("the store did not delete the tag %s:%s alone (%d): %s", name, version, resp.StatusCode, b) + } + return nil +} + +// Remove deletes the manifest name holds at version, by its digest: every tag naming it goes with it. +func (o *OCI) Remove(name, version string) error { + d, err := o.Digest(name, version) + if err != nil || d == "" { + return err + } + return o.RemoveDigest(name, d) +} + +// RemoveDigest deletes a manifest by its digest; the store's nightly collector frees its blobs. +func (o *OCI) RemoveDigest(name, digest string) error { + resp, b, err := o.do(http.MethodDelete, "/v2/"+name+"/manifests/"+digest, "", nil) + if err != nil { + return err + } + if resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusNotFound { + return fmt.Errorf("the store did not delete %s@%s (%d): %s", name, digest, resp.StatusCode, b) + } + return nil +} diff --git a/modules/distribution/internal/holder/platforms.go b/modules/distribution/internal/holder/platforms.go new file mode 100644 index 000000000..8cc0bf2ee --- /dev/null +++ b/modules/distribution/internal/holder/platforms.go @@ -0,0 +1,117 @@ +package holder + +// **An index's platform manifests go with it, unless a kept index names them** (CAT-AS1: what the artifact sweep did +// over the store's door, ADR 0257, is the holder's now that the sweep asks `remove`). A removal for retention of an +// index removes first every platform manifest it names that no other kept index of the repository names, then the +// index; a kept index is one a tag names or one held (`hold`). A manifest a kept index names is never removed for +// retention, so holding an index keeps its platforms too. + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// spared is every manifest of name a kept index keeps on seat, but the one being removed (except): each tagged +// manifest and held digest, and the platform manifests each of those names. +func (v *Verbs) spared(seat, name, except string) (map[string]string, error) { + keepers := map[string]string{} // digest -> why it is kept + tags, err := v.Catalog.Versions(name) + if err != nil { + return nil, err + } + for _, t := range tags { + d, err := v.Catalog.Digest(name, t) + if err != nil { + return nil, err + } + if d != "" { + keepers[d] = "the tag " + t + } + } + holds, err := v.Holds.List() + if err != nil { + return nil, err + } + for _, h := range holds { + if h.Seat == seat && h.Name == name { + keepers[h.Digest] = "the hold of " + h.Digest + } + } + out := map[string]string{} + for d, why := range keepers { + if d == except { + continue + } + out[d] = why + children, err := v.Catalog.Platforms(name, d) + if err != nil { + return nil, fmt.Errorf("reading the kept %s@%s: %w", name, d, err) + } + for _, c := range children { + out[c] = "the index " + d + ", kept by " + why + } + } + return out, nil +} + +// removeForRetention removes name@digest and, for an index, the platform manifests no kept index names; a manifest a +// kept index names is refused. Any read that fails removes nothing. +func (v *Verbs) removeForRetention(seat, name, digest string) error { + children, err := v.Catalog.Platforms(name, digest) + if err != nil { + return fmt.Errorf("remove: the store cannot read %s@%s, so nothing is removed: %w", name, digest, err) + } + spared, err := v.spared(seat, name, digest) + if err != nil { + return fmt.Errorf("remove: which manifests a kept index of %s names cannot be read, so nothing is removed: %w", name, err) + } + if why, kept := spared[digest]; kept { + return fmt.Errorf("remove: refused: %s@%s is kept by %s", name, digest, why) + } + for _, c := range children { + if _, kept := spared[c]; kept { + continue + } + if err := v.Catalog.RemoveDigest(name, c); err != nil { + return fmt.Errorf("remove: the store did not remove %s@%s, a platform of %s: %w", name, c, digest, err) + } + } + if err := v.Catalog.RemoveDigest(name, digest); err != nil { + return fmt.Errorf("remove: the store did not remove %s@%s: %w", name, digest, err) + } + return nil +} + +// Platforms is the manifests an index names, by digest: none for a manifest that is not an index, or one the store +// does not hold. +func (o *OCI) Platforms(name, digest string) ([]string, error) { + resp, b, err := o.do(http.MethodGet, "/v2/"+name+"/manifests/"+digest, "", nil, + "application/vnd.oci.image.index.v1+json", "application/vnd.docker.distribution.manifest.list.v2+json", + mediaManifest, "application/vnd.docker.distribution.manifest.v2+json") + if err != nil { + return nil, err + } + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("the store answered %d for %s@%s", resp.StatusCode, name, digest) + } + var doc struct { + Manifests []struct { + Digest string `json:"digest"` + } `json:"manifests"` + } + if err := json.Unmarshal(b, &doc); err != nil { + return nil, fmt.Errorf("%s@%s is not a manifest: %w", name, digest, err) + } + var out []string + for _, m := range doc.Manifests { + if strings.HasPrefix(m.Digest, "sha256:") { + out = append(out, m.Digest) + } + } + return out, nil +} diff --git a/modules/distribution/internal/holder/transfer.go b/modules/distribution/internal/holder/transfer.go new file mode 100644 index 000000000..6f2a4e5c3 --- /dev/null +++ b/modules/distribution/internal/holder/transfer.go @@ -0,0 +1,351 @@ +package holder + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "slices" + "strings" + "time" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" + "git.novox.be/novox/mesh-delivery/sdk/parts" +) + +// The bounds of incoming (design 51 *Publishing*, ADR 0611): a part is at most 1 MiB, a transfer with no part for 24 +// hours is dropped, and past 80 % of the cap the holder writes a finding. The cap is the one incoming's binding carries +// (the bus's own bound of the object store, read through the runtime); IncomingCap is the manifest's 8 GiB, the most +// the objects template allows, said only where no binding is read (a test), and the manifest's test holds the two equal. +const ( + MaxPart = 1 << 20 + IncomingCap = 8 << 30 + TransferIdle = 24 * time.Hour + IncomingFullPct = 80 + // IncomingObjects and TransfersState are the object store and the state distribution asks as bus state. + IncomingObjects = "incoming" + TransfersState = "transfers" + // IdleState is the state of one key per open transfer with its own time to live (a state declared key-ttl): the + // bus's marker of its end is the event that drops a transfer left with no part for TransferIdle (ADR 0611 §3), never + // a timer. + IdleState = "idle" +) + +// The kinds a seat holds. +const ( + KindImage = "image" + KindBundle = "bundle" + KindArchive = "archive" + KindUpstream = "upstream" +) + +// Part is one file of a publish: an image's layer, config or manifest, a bundle's or an archive's blob, by its digest. +type Part struct { + Digest string `json:"digest"` + Size int64 `json:"size"` + // MediaType says what the part is to the store; an image's manifest names its own. + MediaType string `json:"media_type,omitempty"` +} + +// PublishRequest is the body of `publish` (ADR 0611 §1). +type PublishRequest struct { + Transfer string `json:"transfer"` + Name string `json:"name"` + Version string `json:"version,omitempty"` + Tag string `json:"tag,omitempty"` + Kind string `json:"kind"` + Fingerprint string `json:"fingerprint,omitempty"` + Commit string `json:"commit,omitempty"` + // Digest is the digest the build recorded: an image's manifest, a bundle's or an archive's blob. + Digest string `json:"digest"` + Parts []Part `json:"parts"` +} + +// PublishAnswer is what `publish` answers: the parts the holder lacks, which the build module sends; none when it +// holds them all and the publish ended (Published names how). +type PublishAnswer struct { + Transfer string `json:"transfer"` + Missing []string `json:"missing"` + // Ended says the publish ended at once: everything was held, `published` or `refused` is said. + Ended bool `json:"ended,omitempty"` +} + +// PartRequest is the body of `publish-part` (ADR 0611 §2): one chunk of one part. +type PartRequest struct { + Transfer string `json:"transfer"` + Digest string `json:"digest"` + Sequence int `json:"sequence"` + Last bool `json:"last"` + Data []byte `json:"data"` +} + +// Opening is what distribution keeps of a publish beside the receiving side's own fields: the request and its seat. +type Opening struct { + PublishRequest + Seat string `json:"seat"` +} + +// Transfer is one open publish, kept in the state transfers; its chunks are objects of incoming. The receiving side is +// mesh-sdk's seat-neutral parts.Receiver (ADR 0627 §2); distribution binds it to the seats it holds. +type Transfer = parts.Transfer[Opening] + +// Event is the body of `published`, `refused` and `removed` on `.events` (design 51 *One contract*): the name, +// version, kind, tag, digest, fingerprint, commit and holder; `refused` its reason, `removed` its reason too. +type Event struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` + Kind string `json:"kind"` + Tag string `json:"tag,omitempty"` + Digest string `json:"digest"` + Fingerprint string `json:"fingerprint,omitempty"` + Commit string `json:"commit,omitempty"` + Holder string `json:"holder"` + Transfer string `json:"transfer,omitempty"` + // Reference is the seat's answer: `//@sha256:` (ADR 0605 §9). + Reference string `json:"reference,omitempty"` + Reason string `json:"reason,omitempty"` + // Tags are, for kept, the other tags still naming its digest. + Tags []string `json:"tags,omitempty"` + // By is who asked a removal, hold or release: the caller the tool runner proved. + By string `json:"by,omitempty"` + // ID is the event's id on the bus when the outcome has one of its own (a mirror ask's): the same outcome said + // twice, by a redelivery or a second follower, is one message. Empty: the runtime makes one. + ID string `json:"-"` +} + +// The events of the seats. +const ( + EventPublished = "published" + EventRefused = "refused" + EventRemoved = "removed" + EventKept = "kept" + EventHeld = "held" + EventReleased = "released" +) + +// Objects is distribution's own object store incoming. +type Objects = parts.Objects + +// TransferState is the state transfers. +type TransferState = parts.Transfers[Opening] + +// Builder is the builder's tool builder_versions, asked before anything is stored (ADR 0605 §4). +type Builder interface { + Recorded(name, version, digest string) (ok bool, why string, err error) +} + +// Store is the artifact store behind the loopback, written by distribution's own process alone. +type Store interface { + // Has says whether the store holds a blob of name. + Has(name, digest string) (bool, error) + // Put stores a publish's parts under its name and version and answers the reference's path (SeatReference). + Put(r PublishRequest, parts map[string][]byte) (reference string, err error) + // Held is the reference's path of name at version when the store already holds digest under it: a rebuild of a held + // fingerprint answers it and stores nothing. + Held(name, version, digest string) (reference string, ok bool, err error) +} + +// SeatReference is a reference in the form the seat answers (novox/hq ADR 0605 §9): the seat's name, then the path the +// store answers (`@sha256:`), as the registry clients' reference.Seats.InRegistrySeat reads it back. Nothing set on the process names +// a reach: the seat's name is the registry part. +func SeatReference(seat, path string) string { return seat + "/" + path } + +// Say says an event on a seat's family `.events`. +type Say func(seat, event string, body Event) error + +// Finding writes (raised true) or clears distribution's finding of a kind for a subject. +type Finding func(kind, subject, words string, raised bool) + +// Holder answers the verbs. +type Holder struct { + Callers Callers + Objects Objects + Transfers TransferState + Builder Builder + Store Store + Say Say + Finding Finding + Now func() time.Time + // Cap reads incoming's cap from its binding (parts.BusCap); IncomingCap when nil or when the binding names none. + Cap func() (uint64, error) + // Record records a hand act through the seat mesh-hand-acts (meshhandactsclient.Recorder); a drop by hand is refused + // without it. + Record func(meshhandactsclient.Request) error + // Idle writes a transfer's idle key with its own time to live, once (parts.BusArm over IdleState); the receiver + // arms it on each open that leaves a transfer open. Nil arms nothing. + Idle func(id string, ttl time.Duration) error +} + +func (h *Holder) now() time.Time { + if h.Now != nil { + return h.Now().UTC() + } + return time.Now().UTC() +} + +// CapNow is incoming's cap as its binding carries it. +func (h *Holder) CapNow() uint64 { return h.receiver().CapNow() } + +// receiver binds mesh-sdk's receiving side to distribution's incoming, its transfers state, the builder's check, its +// store and its seats' events. +func (h *Holder) receiver() *parts.Receiver[Opening] { + return &parts.Receiver[Opening]{Objects: h.Objects, Transfers: h.Transfers, Cap: IncomingCap, Bound: h.Cap, + MaxChunk: MaxPart, Idle: TransferIdle, Arm: h.Idle, Full: IncomingFullPct, Now: h.now, + // Only the missing layers are sent: a part the store already holds under the name counts as held. + Holds: func(t Transfer, digest string) bool { + ok, err := h.Store.Has(t.Shape.Name, digest) + return err == nil && ok + }, + // builder_versions is asked before anything is stored (ADR 0605 §4). + Check: func(t Transfer) error { + if t.Shape.Version == "" { + return nil + } + ok, why, err := h.Builder.Recorded(t.Shape.Name, t.Shape.Version, t.Shape.Digest) + if err != nil { + return errors.New("builder_versions did not answer, so nothing is stored: " + err.Error()) + } + if !ok { + return errors.New("the builder did not number it: " + why) + } + return nil + }, + Store: func(t Transfer, held map[string][]byte) error { + path, err := h.Store.Put(t.Shape.PublishRequest, held) + ref := SeatReference(t.Shape.Seat, path) + if err != nil { + reason := "the store did not take it: " + err.Error() + return errors.Join(errors.New("refused: "+reason), h.refused(t, reason)) + } + h.Finding("publish-refused", t.Shape.Name+"@"+t.Shape.Version, "", false) + // Room was found again: a refusal for want of it clears with the first publish the holder takes. + h.Finding("publish-refused", IncomingObjects, "", false) + return h.Say(t.Shape.Seat, EventPublished, h.eventOf(t.Shape.Seat, t.Shape.PublishRequest, ref, "")) + }, + Refuse: h.refused, + Filled: func(used, cap uint64, full bool) { + h.Finding("incoming-full", IncomingObjects, fmt.Sprintf("incoming holds %d of its %d bytes (%d %%); registry_incoming lists and drops transfers", used, cap, used*100/cap), full) + }} +} + +// refused writes the finding and says refused on the transfer's seat. A refusal for want of room is the holder's, +// not the file's: its finding stands on incoming and clears when the holder takes a publish again. +func (h *Holder) refused(t Transfer, reason string) error { + subject := t.Shape.Name + "@" + t.Shape.Version + if parts.RefusedAtCap(reason) { + subject = IncomingObjects + } + h.Finding("publish-refused", subject, reason, true) + return h.Say(t.Shape.Seat, EventRefused, h.eventOf(t.Shape.Seat, t.Shape.PublishRequest, "", reason)) +} + +func answerOf(r parts.Received) PublishAnswer { + return PublishAnswer{Transfer: r.ID, Missing: r.Lacks, Ended: r.Ended} +} + +// Publish is `publish` on seat for the proved caller. +func (h *Holder) Publish(seat, caller string, r PublishRequest) (PublishAnswer, error) { + if err := h.Callers.Allowed(VerbPublish, caller); err != nil { + return PublishAnswer{}, err + } + if err := checkPublish(seat, r); err != nil { + return PublishAnswer{}, err + } + // A rebuild of a held fingerprint answers the held version and stores nothing. + if r.Version != "" { + if ref, ok, err := h.Store.Held(r.Name, r.Version, r.Digest); err != nil { + return PublishAnswer{}, fmt.Errorf("publish: the store cannot say whether it holds %s@%s: %w", r.Name, r.Version, err) + } else if ok { + return PublishAnswer{Transfer: r.Transfer, Ended: true}, h.Say(seat, EventPublished, h.eventOf(seat, r, SeatReference(seat, ref), "")) + } + } + wants := make([]parts.Want, 0, len(r.Parts)) + for _, p := range r.Parts { + wants = append(wants, parts.Want{Digest: p.Digest, Size: p.Size}) + } + got, err := h.receiver().Open(r.Transfer, caller, wants, Opening{PublishRequest: r, Seat: seat}) + if err != nil { + return answerOf(got), fmt.Errorf("publish: %w", err) + } + return answerOf(got), nil +} + +// PublishPart is `publish-part`: one chunk, from the caller that opened the transfer alone. +func (h *Holder) PublishPart(seat, caller string, r PartRequest) (PublishAnswer, error) { + if err := h.Callers.Allowed(VerbPublishPart, caller); err != nil { + return PublishAnswer{}, err + } + got, err := h.receiver().Take(r.Transfer, caller, r.Digest, r.Sequence, r.Last, r.Data, func(t Transfer) error { + if t.Shape.Seat != seat { + return fmt.Errorf("transfer %s is %s's, not %s's", r.Transfer, t.Shape.Seat, seat) + } + return nil + }) + if err != nil { + return answerOf(got), fmt.Errorf("publish-part: %w", err) + } + return answerOf(got), nil +} + +func (h *Holder) eventOf(seat string, r PublishRequest, ref, reason string) Event { + return Event{Name: r.Name, Version: r.Version, Kind: r.Kind, Tag: r.Tag, Digest: r.Digest, Fingerprint: r.Fingerprint, + Commit: r.Commit, Holder: Self, Transfer: r.Transfer, Reference: ref, Reason: reason} +} + +// Drop drops a transfer by hand (registry_incoming) or because it was idle for TransferIdle: its chunks and its key. +func (h *Holder) Drop(id string) (Transfer, error) { return h.receiver().Drop(id) } + +// DropByHand is registry_incoming's drop: by a person, with why, recorded as a hand act through mesh-hand-acts before +// anything goes; an act not recorded leaves the transfer open. +func (h *Holder) DropByHand(id, by, why string) (Transfer, error) { + if strings.TrimSpace(why) == "" { + return Transfer{}, errors.New("a drop is a hand act: say why") + } + if h.Record == nil { + return Transfer{}, errors.New("no hand act can be recorded here, so nothing is dropped") + } + t, found, err := h.Transfers.Get(id) + if err != nil { + return Transfer{}, fmt.Errorf("transfer %s cannot be read, so nothing is dropped: %w", id, err) + } + if !found { + return Transfer{}, fmt.Errorf("transfer %s is not open", id) + } + what := fmt.Sprintf("dropped the open transfer %s of incoming (%s@%s on %s, opened by %s, %d parts lacked), by %s", id, t.Shape.Name, t.Shape.Version, t.Shape.Seat, t.Opener, len(t.Lacks()), by) + if err := h.Record(meshhandactsclient.Request{What: what, Why: why, Cause: "registry_incoming"}); err != nil { + return Transfer{}, fmt.Errorf("the hand act cannot be recorded, so transfer %s stays open: %w", id, err) + } + return h.Drop(id) +} + +// DropIdle drops every transfer with no part for TransferIdle, answering them: registry_incoming's look, by hand. The +// drop itself follows each transfer's idle key (Expired); the objects template keeps for good, so nothing else does. +func (h *Holder) DropIdle() ([]string, error) { return h.receiver().DropIdle() } + +// Expired is told that a transfer's idle key ended (the bus's marker, parts.FollowIdle): the receiver drops a transfer +// with no part for TransferIdle, its chunks with it, arms one that took a part since again for the rest, and leaves one +// already gone. It answers whether it dropped the transfer. +func (h *Holder) Expired(id string) (bool, error) { return h.receiver().Expired(id) } + +func checkPublish(seat string, r PublishRequest) error { + switch { + case !slices.Contains(Seats, seat): + return fmt.Errorf("publish: distribution holds no seat %s", seat) + case strings.TrimSpace(r.Name) == "": + return errors.New("publish: no name") + case !strings.HasPrefix(r.Digest, "sha256:"): + return fmt.Errorf("publish: digest %q is no sha256 digest", r.Digest) + } + switch { + case seat == ImageSeat && r.Kind != KindImage && r.Kind != KindUpstream: + return fmt.Errorf("publish: %s holds images, not a %s", seat, r.Kind) + case seat == ArtifactSeat && r.Kind != KindBundle && r.Kind != KindArchive: + return fmt.Errorf("publish: %s holds bundles and archives, not a %s", seat, r.Kind) + } + return nil +} + +func sum(b []byte) string { + s := sha256.Sum256(b) + return hex.EncodeToString(s[:]) +} diff --git a/modules/distribution/internal/holder/verbs.go b/modules/distribution/internal/holder/verbs.go new file mode 100644 index 000000000..bf4b5f0b6 --- /dev/null +++ b/modules/distribution/internal/holder/verbs.go @@ -0,0 +1,439 @@ +package holder + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "slices" + "sort" + "strings" + "time" + + reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" +) + +// Catalog is what the store answers of what it holds. +type Catalog interface { + // Versions are the versions (tags) of name, sorted. + Versions(name string) ([]string, error) + // Digest is the digest name holds at version, empty when none. + Digest(name, version string) (string, error) + // Remove deletes name at version by its manifest's digest, every tag naming that digest with it; the store's + // collector frees its blobs. + Remove(name, version string) error + // RemoveTag deletes the tag version of name alone, the manifest staying under every other tag naming it; a store + // that cannot delete a tag alone answers ErrNoTagAlone and removes nothing. + RemoveTag(name, version string) error + // RemoveDigest deletes an untagged manifest of name by its digest (retention). + RemoveDigest(name, digest string) error + // Platforms are the manifests an index of name names; none for a manifest that is not an index or is not held. + Platforms(name, digest string) ([]string, error) + // Mirror copies an outside image, pinned by digest, into upstream//, answering the reference. + Mirror(source, digest string) (string, error) + // Reference is the seat's answer for name at digest, known before anything is copied. + Reference(name, digest string) string +} + +// MirrorsState names the state of the copies `mirror` took on. +const MirrorsState = "mirrors" + +// Mirrors is the state mirrors: each copy `mirror` took on, kept until its outcome is said, so a copy cut by a +// restart is taken on again from the state's watch. +type Mirrors interface { + // Ask keeps r under its key; an ask already kept is no fault (the copy is taken on once). + Ask(key string, r MirrorRequest) error + // Claim writes r (its Claim set) under key at revision, compare-and-set: false, and no error, when another + // writer wrote the key first. + Claim(key string, r MirrorRequest, revision uint64) (bool, error) + Delete(key string) error + // List is every ask kept, by its key: what registry_mirrors reads. + List() (map[string]MirrorRequest, error) + // Put writes r under key whatever it held: an ask asked again by hand, its claim let go. + Put(key string, r MirrorRequest) error +} + +// HoldState is distribution's own state holds (bus state, template state; ADR 0617 §2). +type HoldState interface { + Get(key string) (Hold, bool, error) + Put(h Hold) error + Delete(key string) error + List() ([]Hold, error) +} + +// Hold keeps one untagged bundle, archive or copied base from retention's removal. +type Hold struct { + Seat string `json:"seat"` + Name string `json:"name"` + Kind string `json:"kind"` + Digest string `json:"digest"` + By string `json:"by"` + Why string `json:"why,omitempty"` + Since time.Time `json:"since"` +} + +// Key is the hold's key in the state. +func (h Hold) Key() string { + return h.Seat + "." + strings.ReplaceAll(strings.ReplaceAll(h.Name, ".", "="), "@", "=") + "." + strings.TrimPrefix(h.Digest, "sha256:") +} + +// Verbs answers the verbs other than the publish: versions, remove, hold, release, tags and mirror. +type Verbs struct { + *Holder + Catalog Catalog + Holds HoldState + Mirrors Mirrors + // Files are the store's own files, which list reads (ADR 0632 §3). + Files StoreFiles +} + +// VersionsAnswer is `versions`: the versions the seat holds of name. +type VersionsAnswer struct { + Seat string `json:"seat"` + Name string `json:"name"` + Versions []string `json:"versions"` +} + +// Versions is `versions`. +func (v *Verbs) Versions(seat, caller, name string) (VersionsAnswer, error) { + if err := v.Callers.Allowed(VerbVersions, caller); err != nil { + return VersionsAnswer{}, err + } + if strings.TrimSpace(name) == "" { + return VersionsAnswer{}, errors.New("versions: no name") + } + got, err := v.Catalog.Versions(name) + if err != nil { + return VersionsAnswer{}, fmt.Errorf("versions: the store cannot list %s: %w", name, err) + } + if got == nil { + got = []string{} + } + return VersionsAnswer{Seat: seat, Name: name, Versions: got}, nil +} + +// RemoveRequest is `remove`: a tagged version, or with the reason retention an untagged one by its digest. +type RemoveRequest struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` + Digest string `json:"digest,omitempty"` + Kind string `json:"kind,omitempty"` + Reason string `json:"reason"` +} + +// ReasonRetention is the reason of the sweep's removal of an untagged version (ADR 0610 §3). +// The seat definitions name it (the removal reasons' enum), so the sweep and the holder share one value. +const ReasonRetention = reg.RemoveInputReasonRetention + +// Removal is what remove answers: Removed, or Kept with its reason and the other tags naming its digest. +type Removal struct { + Removed bool + Kept bool + Reason string + Tags []string +} + +// ReasonSharedDigest is why a removal was kept: another tag names its digest, and the store cannot remove a tag alone. +const ReasonSharedDigest = reg.RemoveAnswerReasonSharedDigest + +// Remove is `remove`: the builder removes a tagged version; the artifact sweep's holder an untagged bundle, archive or +// copied base with the reason retention, which a hold keeps. Every removal says `removed` with its reason. +func (v *Verbs) Remove(seat, caller string, r RemoveRequest) (Removal, error) { + if err := v.Callers.Allowed(VerbRemove, caller); err != nil { + return Removal{}, err + } + if strings.TrimSpace(r.Name) == "" || strings.TrimSpace(r.Reason) == "" { + return Removal{}, errors.New("remove: a name and a reason are required") + } + ev := Event{Name: r.Name, Version: r.Version, Kind: r.Kind, Digest: r.Digest, Holder: Self, Reason: r.Reason, By: caller} + switch { + case r.Reason == ReasonRetention: + if CallerModule(caller) != SweepHolder { + return Removal{}, fmt.Errorf("remove: refused for %s: a removal for retention is the artifact sweep's alone", caller) + } + if r.Digest == "" { + return Removal{}, errors.New("remove: a removal for retention names the digest") + } + if _, held, err := v.Holds.Get(Hold{Seat: seat, Name: r.Name, Digest: r.Digest}.Key()); err != nil { + return Removal{}, fmt.Errorf("remove: the holds cannot be read, so nothing is removed: %w", err) + } else if held { + return Removal{}, fmt.Errorf("remove: refused: %s@%s is held; release it first", r.Name, r.Digest) + } + if err := v.removeForRetention(seat, r.Name, r.Digest); err != nil { + return Removal{}, err + } + default: + if r.Version == "" || !strings.Contains(r.Version, "-") { + return Removal{}, fmt.Errorf("remove: refused: %s@%s has no tag; only a tagged version is removed, but for retention", r.Name, r.Version) + } + d, err := v.Catalog.Digest(r.Name, r.Version) + if err != nil { + return Removal{}, fmt.Errorf("remove: the store cannot read %s@%s: %w", r.Name, r.Version, err) + } + if d != "" { + // Reproducible builds share a digest between a branch's tagged version and the trunk's numbered one: a + // manifest deleted by its digest would take the trunk's version with it (security review F1). When another + // tag names the digest, the tag alone goes. A store that cannot remove a tag alone (registry 2.8.3) keeps the + // version whole, its tag and its bytes, which the other tags name anyway: remove answers and says kept with + // the reason shared-digest and those tags, no refusal, so the builder raises no retry. Any other failure is + // refused. + others, err := v.otherTags(r.Name, r.Version, d) + if err != nil { + return Removal{}, fmt.Errorf("remove: the store cannot say which other tags of %s name %s, so nothing is removed: %w", r.Name, d, err) + } + if len(others) > 0 { + if err := v.Catalog.RemoveTag(r.Name, r.Version); errors.Is(err, ErrNoTagAlone) { + sort.Strings(others) + ev.Digest, ev.Reason, ev.Tags = d, ReasonSharedDigest, others + kept := Removal{Kept: true, Reason: ReasonSharedDigest, Tags: others} + return kept, v.Say(seat, EventKept, ev) + } else if err != nil { + return Removal{}, fmt.Errorf("remove: refused: %s@%s shares its digest %s with %v, and the store did not remove the tag alone, so nothing is removed: %w", r.Name, r.Version, d, others, err) + } + } else if err := v.Catalog.Remove(r.Name, r.Version); err != nil { + return Removal{}, fmt.Errorf("remove: the store did not remove %s@%s: %w", r.Name, r.Version, err) + } + } + ev.Digest = d // a version already gone is said removed all the same, so the builder's retry ends + } + return Removal{Removed: true}, v.Say(seat, EventRemoved, ev) +} + +// otherTags are the tags of name but version that name digest. +func (v *Verbs) otherTags(name, version, digest string) ([]string, error) { + tags, err := v.Catalog.Versions(name) + if err != nil { + return nil, err + } + var out []string + for _, t := range tags { + if t == version { + continue + } + d, err := v.Catalog.Digest(name, t) + if err != nil { + return nil, err + } + if d == digest { + out = append(out, t) + } + } + return out, nil +} + +// HoldRequest is `hold` and `release`. +type HoldRequest struct { + Name string `json:"name"` + Kind string `json:"kind"` + Digest string `json:"digest"` + Why string `json:"why,omitempty"` +} + +// HoldOne is `hold`: the artifact sweep's holder alone; an image is never held (its own rules keep images). +func (v *Verbs) HoldOne(seat, caller string, r HoldRequest) error { + if err := v.Callers.Allowed(VerbHold, caller); err != nil { + return err + } + // The seat's contract names no kind: on the image seat a hold is of a copied base (upstream/…), never an image of + // its own; on the artifact seat of a bundle or an archive. + if r.Kind == "" && seat == ImageSeat && strings.HasPrefix(r.Name, "upstream/") { + r.Kind = KindUpstream + } + switch { + case r.Kind == "" && seat == ArtifactSeat: + case r.Kind == KindBundle, r.Kind == KindArchive, r.Kind == KindUpstream: + default: + return fmt.Errorf("hold: refused: a %s is never held; only an untagged bundle, archive or copied base", r.Kind) + } + if r.Name == "" || !strings.HasPrefix(r.Digest, "sha256:") { + return errors.New("hold: a name and a sha256 digest are required") + } + h := Hold{Seat: seat, Name: r.Name, Kind: r.Kind, Digest: r.Digest, By: caller, Why: r.Why, Since: v.now()} + if err := v.Holds.Put(h); err != nil { + return fmt.Errorf("hold: the holds did not take it: %w", err) + } + return v.Say(seat, EventHeld, Event{Name: r.Name, Kind: r.Kind, Digest: r.Digest, Holder: Self, By: caller}) +} + +// Release is `release`: the artifact sweep's holder, or the operator's session through registry_holds (a hand act, +// recorded by the tool); distribution's own account is never on its list. +func (v *Verbs) Release(seat, caller string, r HoldRequest) (bool, error) { + if CallerModule(caller) == Self && !strings.HasPrefix(caller, "person.") { + return false, fmt.Errorf("release: refused for %s: distribution cannot release in its own name", caller) + } + if err := v.Callers.Allowed(VerbRelease, caller); err != nil { + return false, err + } + key := Hold{Seat: seat, Name: r.Name, Digest: r.Digest}.Key() + h, held, err := v.Holds.Get(key) + if err != nil { + return false, err + } + if !held { + return false, nil // nothing held: the outcome asked holds already, and nothing is said (the seat's definition) + } + if err := v.Holds.Delete(key); err != nil { + return false, err + } + return true, v.Say(seat, EventReleased, Event{Name: h.Name, Kind: h.Kind, Digest: h.Digest, Holder: Self, By: caller, Reason: r.Why}) +} + +// Tags is `tags` on the image seat: the tags of an image. +func (v *Verbs) Tags(seat, caller, name string) (VersionsAnswer, error) { + if seat != ImageSeat { + return VersionsAnswer{}, fmt.Errorf("tags: %s serves no tags", seat) + } + if err := v.Callers.Allowed(VerbTags, caller); err != nil { + return VersionsAnswer{}, err + } + got, err := v.Catalog.Versions(name) + if err != nil { + return VersionsAnswer{}, err + } + return VersionsAnswer{Seat: seat, Name: name, Versions: got}, nil +} + +// MirrorRequest is `mirror`: an outside image by its pinned digest; By is the proved caller, kept with the ask. +type MirrorRequest struct { + Source string `json:"source"` + Digest string `json:"digest"` + By string `json:"by,omitempty"` + // ID is the ask's own id, made when it is asked: the id of the event that says its end. + ID string `json:"id,omitempty"` + // Claim is the follower that took the copy on: none until one claims it. + Claim string `json:"claim,omitempty"` +} + +// MirrorsKey is an ask's key in the state mirrors: the copy's name and digest, in a key's characters. +func MirrorsKey(name, digest string) string { + return strings.NewReplacer(":", "_", "@", "_").Replace(name + "/" + digest) +} + +// Mirror is `mirror` on the image seat: mesh-build-image alone; an image pinned by no digest is refused. It answers at +// once with the reference the copy will have and keeps the ask in the state mirrors; the copy runs on that state's +// watch (Mirrored), which says published or refused on the seat's events when it ends. The caller waits on that +// event, not on the call, so a large base never outlasts a call's bound. +func (v *Verbs) Mirror(seat, caller string, r MirrorRequest) (string, error) { + if seat != ImageSeat { + return "", fmt.Errorf("mirror: %s serves no mirror", seat) + } + if err := v.Callers.Allowed(VerbMirror, caller); err != nil { + return "", err + } + if r.Source == "" || !strings.HasPrefix(r.Digest, "sha256:") { + return "", errors.New("mirror: refused: an upstream image is copied only by its pinned digest") + } + src, err := ParseSource(r.Source) + if err != nil { + return "", err + } + r.By, r.Claim = caller, "" + if r.ID == "" { + id := make([]byte, 8) + _, _ = rand.Read(id) + r.ID = "mirror-" + hex.EncodeToString(id) + } + if err := v.Mirrors.Ask(MirrorsKey(src.Name(), r.Digest), r); err != nil { + return "", fmt.Errorf("mirror: the copy of %s@%s cannot be taken on: %w", r.Source, r.Digest, err) + } + return SeatReference(seat, v.Catalog.Reference(src.Name(), r.Digest)), nil +} + +// Mirrored runs one asked copy and says its outcome on the image seat's events: published with the reference, or +// refused with the reason; the ask leaves the state either way. An outcome that cannot be said keeps the ask, so the +// next watch takes it on again. +func (v *Verbs) Mirrored(r MirrorRequest) error { + src, err := ParseSource(r.Source) + if err != nil { + return err + } + e := Event{Name: src.Name(), Kind: KindUpstream, Digest: r.Digest, Holder: Self, By: r.By, ID: r.ID} + event := EventPublished + path, err := v.Catalog.Mirror(r.Source, r.Digest) + e.Reference = SeatReference(ImageSeat, path) + if err != nil { + event, e.Reference, e.Reason = EventRefused, "", fmt.Sprintf("%s@%s was not copied: %v", r.Source, r.Digest, err) + } + if err := v.Say(ImageSeat, event, e); err != nil { + return fmt.Errorf("mirror: the outcome of %s@%s cannot be said: %w", r.Source, r.Digest, err) + } + return v.Mirrors.Delete(MirrorsKey(src.Name(), r.Digest)) +} + +// Take decides one change of the state mirrors for the follower self: an ask no follower claimed, or one claimed +// before this follower's watch began (its claimant may have been cut by a restart), is claimed by compare-and-set at +// revision and copied by the follower that won; an ask claimed since, by self or by another, is passed. Two followers +// that both start on the same cut ask can both win in turn; the outcome then carries the ask's id, so it is said once. +func (v *Verbs) Take(key string, r MirrorRequest, revision uint64, current bool, self string) error { + if r.Claim != "" && (r.Claim == self || !current) { + return nil + } + r.Claim = self + won, err := v.Mirrors.Claim(key, r, revision) + if err != nil { + return fmt.Errorf("mirror: the ask %s cannot be claimed: %w", key, err) + } + if !won { + return nil + } + return v.Mirrored(r) +} + +// MirrorsNow lists every copy `mirror` took on and has not ended, by its key, for registry_mirrors: what waits (no +// claim) and what a follower took on (its claim). +func (v *Verbs) MirrorsNow() (map[string]MirrorRequest, error) { return v.Mirrors.List() } + +// MirrorAgain asks a kept copy again by hand (registry_mirrors, a hand act): its claim is let go, so the state's watch +// hands it to a follower, which takes it on as a new ask. A copy whose follower was cut, or that a follower holds and +// does not end, goes on this way without a restart. +func (v *Verbs) MirrorAgain(key string) (MirrorRequest, error) { + all, err := v.Mirrors.List() + if err != nil { + return MirrorRequest{}, fmt.Errorf("mirror: the asks cannot be read: %w", err) + } + r, ok := all[key] + if !ok { + return MirrorRequest{}, fmt.Errorf("mirror: no copy is kept under %s", key) + } + r.Claim = "" + if err := v.Mirrors.Put(key, r); err != nil { + return MirrorRequest{}, fmt.Errorf("mirror: %s is not asked again: %w", key, err) + } + return r, nil +} + +// MirrorDrop drops a kept copy by hand (registry_mirrors, a hand act with who and why): it says refused on the image +// seat's events with that reason, under the ask's id, so a waiter on the copy ends, and the ask leaves the state. +func (v *Verbs) MirrorDrop(key, by, why string) (MirrorRequest, error) { + if strings.TrimSpace(why) == "" { + return MirrorRequest{}, errors.New("mirror: a drop is a hand act: say why") + } + all, err := v.Mirrors.List() + if err != nil { + return MirrorRequest{}, fmt.Errorf("mirror: the asks cannot be read: %w", err) + } + r, ok := all[key] + if !ok { + return MirrorRequest{}, fmt.Errorf("mirror: no copy is kept under %s", key) + } + src, err := ParseSource(r.Source) + if err != nil { + return MirrorRequest{}, err + } + e := Event{Name: src.Name(), Kind: KindUpstream, Digest: r.Digest, Holder: Self, By: by, ID: r.ID, + Reason: fmt.Sprintf("%s@%s was not copied: dropped by hand by %s: %s", r.Source, r.Digest, by, why)} + if err := v.Say(ImageSeat, EventRefused, e); err != nil { + return MirrorRequest{}, fmt.Errorf("mirror: the drop of %s cannot be said, so the ask stays: %w", key, err) + } + return r, v.Mirrors.Delete(key) +} + +// HeldNow lists the holds, for registry_holds. +func (v *Verbs) HeldNow() ([]Hold, error) { + all, err := v.Holds.List() + if err != nil { + return nil, err + } + slices.SortFunc(all, func(a, b Hold) int { return strings.Compare(a.Key(), b.Key()) }) + return all, nil +} diff --git a/modules/distribution/internal/holder/window.go b/modules/distribution/internal/holder/window.go new file mode 100644 index 000000000..4ba0971e8 --- /dev/null +++ b/modules/distribution/internal/holder/window.go @@ -0,0 +1,168 @@ +package holder + +import ( + "fmt" + + "git.novox.be/novox/mesh-delivery/sdk/window" + "slices" + "sort" + "time" +) + +// The two windows distribution keeps (design 51): the old seat name `mesh-artifact-store`, served until every node whose +// declaration names an artifact applied a build whose node-engine states it reads the new names (ADR 0610 §4, ADR 0614 +// §4), and the direct writes to the store, accepted from the mesh until every node's have of every build module runs a +// build that publishes through the seats. Each wakes at distribution's start, on each `delivered` and on the node-engine +// seat's `manifest-reads-changed` (ADR 0618 §4), never on a timer; each reads every node, away ones included, through +// node_haves and `manifest-reads`, never a watch of another module's state (ADR 0596). +const ( + OldNameWindow = "old-name" + DirectWritesWindow = "direct-writes" + // WindowFindingAfter is how long a node holds a window open before it is distribution's finding naming the node. + WindowFindingAfter = 7 * 24 * time.Hour + // The events a window's close says on distribution's own family distribution.events (7 days, 16 MiB, drop + // oldest), never on a seat's `.events`. + OldNameClosedEvent = "old-name-closed" + DirectWritesClosedEvent = "direct-writes-closed" + // NewNamesCapability is the reader capability a node-engine states when it reads the new seat names (ADR 0614 §4). + NewNamesCapability = "seat:" + ImageSeat +) + +// Window is one window's record, kept in distribution's state windows: what registry_old_name and +// registry_direct_writes answer. Its open, holding, since, closed and last holding are mesh-delivery's sdk/window.WindowHeld, +// the part every window keeper shares and steps by one rule (window.StepWindow). +type Window struct { + Name string `json:"name"` + window.WindowHeld + Dropped []DroppedNode `json:"dropped,omitempty"` + // Why says, of each node holding the window, why it holds it: what its read answered, a node-engine whose + // manifest-reads did not answer (an away node, an older node-engine, a timeout) included, so a timeout taken as + // stating nothing is told apart from a node-engine that states the old names. + Why map[string]string `json:"why,omitempty"` +} + +// DroppedNode is one retired node dropped from a window by hand: a hand act. +type DroppedNode struct { + Node string `json:"node"` + By string `json:"by"` + Why string `json:"why"` + At time.Time `json:"at"` +} + +// Closed is the body of a window's closing event. +type Closed struct { + Window string `json:"window"` + Seat string `json:"seat,omitempty"` + At time.Time `json:"at"` + LastHolding []string `json:"last_holding"` + Dropped []DroppedNode `json:"dropped,omitempty"` +} + +// NodeRead is what distribution read of one node: the reader capabilities its node-engine states through +// `manifest-reads` (Serves false while it does not serve the verb: it states nothing; NotServed then says what the +// call answered), whether its declaration names an artifact, and its build modules' haves: whether each runs a build +// that publishes through the seats. +type NodeRead struct { + Node string `json:"node"` + Serves bool `json:"serves"` + NotServed string `json:"not_served,omitempty"` + Stated []string `json:"stated,omitempty"` + NamesArtifact bool `json:"names_artifact"` + BuildModules map[string]bool `json:"build_modules,omitempty"` +} + +// WhyOldName says why a node holds the old name open. +func WhyOldName(n NodeRead) string { + if !n.Serves { + if n.NotServed != "" { + return "its node-engine's manifest-reads did not answer, so it counts as stating nothing: " + n.NotServed + } + return "its node-engine does not serve manifest-reads, so it states nothing" + } + return "its node-engine does not state " + NewNamesCapability +} + +// WhyDirectWrites says why a node holds the direct writes open: its build modules running a build not heard +// publishing through the seats. +func WhyDirectWrites(n NodeRead) string { + var old []string + for m, publishes := range n.BuildModules { + if !publishes { + old = append(old, m) + } + } + sort.Strings(old) + return fmt.Sprintf("its %v run a build not heard publishing through the seats", old) +} + +// HoldingOldName are the nodes holding the old name open: a node whose declaration names an artifact and whose +// node-engine does not state the new names (one not serving manifest-reads states nothing). +func HoldingOldName(nodes []NodeRead, dropped []DroppedNode) []string { + var out []string + for _, n := range nodes { + if isDropped(dropped, n.Node) || !n.NamesArtifact { + continue + } + if !n.Serves || !slices.Contains(n.Stated, NewNamesCapability) { + out = append(out, n.Node) + } + } + sort.Strings(out) + return out +} + +// HoldingDirectWrites are the nodes one of whose build modules' haves runs a build that does not yet publish through +// the seats. +func HoldingDirectWrites(nodes []NodeRead, dropped []DroppedNode) []string { + var out []string + for _, n := range nodes { + if isDropped(dropped, n.Node) { + continue + } + for _, publishes := range n.BuildModules { + if !publishes { + out = append(out, n.Node) + break + } + } + } + sort.Strings(out) + return out +} + +func isDropped(dropped []DroppedNode, node string) bool { + return slices.ContainsFunc(dropped, func(d DroppedNode) bool { return d.Node == node }) +} + +// Step moves the window by mesh-sdk's one rule (window.StepWindow): named is every node the read named, dropped ones +// included, holding those holding the window at now. A read naming no node judges nothing. It answers the next record, +// the closing event when it closed in this step (said once), and the nodes holding it past WindowFindingAfter (the +// finding; cleared when it closes). A window once closed stays closed: a node that holds it later is no reason to serve +// the old name again or to take direct writes again. +func (w Window) Step(named, holding []string, now time.Time) (next Window, closed *Closed, overdue []string) { + next = w + if !w.Open && !w.Closed.IsZero() { + return next, nil, nil + } + held, shut, overdue := window.StepWindow(w.WindowHeld, named, holding, now, WindowFindingAfter) + next.WindowHeld = held + if !held.Open { + next.Why = nil + } + if !shut { + return next, nil, overdue + } + return next, &Closed{Window: w.Name, At: held.Closed, LastHolding: held.LastHolding, Dropped: w.Dropped}, nil +} + +// Drop drops a retired node from the window by hand, a hand act with who and why. +func (w Window) Drop(node, by, why string, now time.Time) (Window, error) { + if node == "" || why == "" { + return w, fmt.Errorf("drop: a node and why are required (a hand act)") + } + if isDropped(w.Dropped, node) { + return w, fmt.Errorf("drop: %s is dropped from %s already", node, w.Name) + } + w.Dropped = append(w.Dropped, DroppedNode{Node: node, By: by, Why: why, At: now}) + return w, nil +} diff --git a/modules/distribution/module.json b/modules/distribution/module.json index 5037f429d..5c6c95753 100644 --- a/modules/distribution/module.json +++ b/modules/distribution/module.json @@ -1,39 +1,142 @@ { "module": "distribution", - "version": "1", - "provides": [ - { - "name": "artifact-store", - "scope": "mesh", - "credential": { - "form": "none" - } - } - ], + "version": "2", + "packages": { + "builds": "0.1" + }, "claims": [ + { + "name": "container-image-registry", + "scope": "mesh", + "serves": [ + "hold", + "list", + "mirror", + "publish", + "publish-part", + "release", + "remove", + "tags", + "versions" + ] + }, + { + "name": "oci-artifact-registry", + "scope": "mesh", + "serves": [ + "hold", + "list", + "publish", + "publish-part", + "release", + "remove", + "versions" + ] + }, { "name": "mesh-artifact-store", "scope": "mesh" } ], + "tools": [ + "registry_callers", + "registry_direct_writes", + "registry_health", + "registry_holds", + "registry_incoming", + "registry_mirrors", + "registry_old_name" + ], + "invokes": [ + "distribution.registry_callers", + "mesh-delivery-builder.builder_versions", + "mesh-delivery-view.node_haves", + "seat:node-engine.manifest-reads", + "seat:mesh-hand-acts.hand-act" + ], + "consumes": [ + "mesh-delivery-view.delivered", + "node-engine.manifest-reads-changed" + ], + "config": { + "callers": { + "type": "string", + "trusted": true, + "runtime": true, + "default": "", + "description": "per verb, the modules that may call it (JSON object of verb to modules; operator for the operator's session); empty is design 51's list" + } + }, + "requires": [ + { + "name": "incoming", + "storage": "object-store", + "template": "objects", + "options": { + "max-mib": 8192 + } + }, + { + "name": "transfers", + "storage": "key-value", + "template": "state" + }, + { + "name": "idle", + "storage": "key-value", + "template": "state", + "options": { + "key-ttl": true + } + }, + { + "name": "holds", + "storage": "key-value", + "template": "state" + }, + { + "name": "windows", + "storage": "key-value", + "template": "state" + }, + { + "name": "mirrors", + "storage": "key-value", + "template": "state" + }, + { + "name": "findings", + "storage": "key-value", + "template": "state" + }, + { + "name": "events", + "storage": "stream", + "template": "events", + "options": { + "events": [ + "old-name-closed", + "direct-writes-closed" + ], + "max-age-days": 7, + "max-mib": 16 + } + } + ], "capabilities": [ "container-runtime" ], "own-secrets": { "broker": "/var/lib/mesh/registry/broker" }, - "serves": { - "artifact-store": { - "port": 5000 - } - }, + "secrets-owner": "distribution", "listens": [ { "name": "registry", "port": 5000, "protocol": "tcp", "from": "mesh", - "why": "every machine pulls images and artifacts from here" + "why": "every machine pulls images and artifacts from here; distribution's program serves it in front of the store" } ], "data": { @@ -49,19 +152,28 @@ ] }, "resources": [ + { + "id": "account", + "type": "user", + "name": "distribution", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh/registry" + }, { "id": "state", "type": "directory", "path": "/var/lib/mesh/registry", "mode": "0700", - "unknown": "program" + "unknown": "program", + "owner": "distribution" }, { "id": "registry-data", "type": "directory", "path": "/var/lib/mesh-registry", "mode": "0700", - "unknown": "program" + "unknown": "program", + "owner": "distribution" }, { "id": "store", @@ -69,13 +181,46 @@ "name": "mesh-registry", "image": "registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373", "ports": [ - "5000:5000" + "127.0.0.1:5001:5000" ], "volumes": [ "/var/lib/mesh-registry:/var/lib/registry" ], "env": { "REGISTRY_STORAGE_DELETE_ENABLED": "true" + }, + "health": { + "kind": "http", + "endpoint": "registry", + "path": "/v2/", + "status": 200, + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "90s" + } + }, + { + "id": "program", + "type": "process", + "name": "distribution", + "artifact": "program", + "run": [ + "./distribution" + ], + "env": { + "MESH_BROKER_FILE": "/var/lib/mesh/registry/broker", + "MESH_REGISTRY_LOOPBACK": "http://127.0.0.1:5001", + "MESH_REGISTRY_LISTEN": ":5000" + }, + "user": "distribution", + "health": { + "kind": "tool", + "tool": "registry_health", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "90s" } }, { @@ -95,5 +240,31 @@ "store" ] } - ] + ], + "build": { + "artifacts": [ + { + "name": "program", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/distribution", + "binary": "distribution" + }, + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/distribution-tools", + "binary": "distribution-tools", + "loads": [ + "distribution-tools" + ], + "env": { + "MESH_REGISTRY_CONTAINER": "mesh-registry" + } + } + ] + } } diff --git a/modules/dmenu/go.mod b/modules/dmenu/go.mod index 53d7c705f..727372804 100644 --- a/modules/dmenu/go.mod +++ b/modules/dmenu/go.mod @@ -2,10 +2,10 @@ module dmenu go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/dmenu/go.sum b/modules/dmenu/go.sum index 02d6daae7..0daef11ff 100644 --- a/modules/dmenu/go.sum +++ b/modules/dmenu/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/dnsmasq/go.mod b/modules/dnsmasq/go.mod index 320911a48..0b4482183 100644 --- a/modules/dnsmasq/go.mod +++ b/modules/dnsmasq/go.mod @@ -2,10 +2,10 @@ module dnsmasq go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/dnsmasq/go.sum b/modules/dnsmasq/go.sum index 02d6daae7..0daef11ff 100644 --- a/modules/dnsmasq/go.sum +++ b/modules/dnsmasq/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/docker-compose/go.mod b/modules/docker-compose/go.mod index d80e746b2..da652c781 100644 --- a/modules/docker-compose/go.mod +++ b/modules/docker-compose/go.mod @@ -2,10 +2,10 @@ module docker-compose go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/docker-compose/go.sum b/modules/docker-compose/go.sum index 02d6daae7..0daef11ff 100644 --- a/modules/docker-compose/go.sum +++ b/modules/docker-compose/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/docker/README.md b/modules/docker/README.md index 2f85be97b..6b893c395 100644 --- a/modules/docker/README.md +++ b/modules/docker/README.md @@ -36,10 +36,10 @@ either. Under ADR 0196 a container copies its machine's resolvers, so no module controller's private network wrote `insecure-registries`; under ADR 0222 the controller writes nothing into this file, and this module states the registry itself (the order below). -- `daemon`: `{"live-restore": true, "insecure-registries": ["${seat:mesh-artifact-store:reach}"]}`, +- `daemon`: `{"live-restore": true, "insecure-registries": ["${seat:container-image-registry:reach}"]}`, merged into the file beside the keys others write. -- `${seat:mesh-artifact-store:reach}` is where this machine reaches the mesh's artifact store - (host:port), filled in by the controller: no binding, no credential, the same address the mesh +- `${seat:container-image-registry:reach}` is where this machine reaches the mesh's artifact store + (host:port), the image seat holder's listen open to the mesh (ADR 0632 §1), filled in by the node-engine: no binding, no credential, the same address the mesh composes into every image it built. Trusting it in the clear is ADR 0082's decision: every path to it is inside the private network's encryption. While no machine on the network holds the store the answer is empty, and the controller drops the empty member, so the list gets nothing. diff --git a/modules/docker/cmd/docker-tools/logrotation_test.go b/modules/docker/cmd/docker-tools/logrotation_test.go index 381df1e2e..fbdef2b97 100644 --- a/modules/docker/cmd/docker-tools/logrotation_test.go +++ b/modules/docker/cmd/docker-tools/logrotation_test.go @@ -68,7 +68,12 @@ func filled(t *testing.T, m manifestForLogs, content string) string { if len(missing) > 0 { t.Fatalf("the content asks for %v, which the manifest gives no preference default", missing) } - return strings.ReplaceAll(out, "${seat:mesh-artifact-store:reach}", "registry.example:5100") + // The store's address is the image seat holder's listen (novox/hq ADR 0632 §1), never the retired provision's. + const reach = "${seat:container-image-registry:reach}" + if strings.Contains(content, "insecure-registries") && !strings.Contains(content, reach) { + t.Fatalf("the runtime trusts no %s: %s", reach, content) + } + return strings.ReplaceAll(out, reach, "registry.example:5100") } func TestTheDefaultLogRotationIsTheOneTheHandSetMachineHas(t *testing.T) { diff --git a/modules/docker/files/daemon.json b/modules/docker/files/daemon.json index 83750f4bd..c8fb9c7ee 100644 --- a/modules/docker/files/daemon.json +++ b/modules/docker/files/daemon.json @@ -1 +1 @@ -{"live-restore": true, "insecure-registries": ["${seat:mesh-artifact-store:reach}"], "log-driver": "json-file", "log-opts": {"max-size": "${config:log-max-size}", "max-file": "${config:log-max-file}"}} +{"live-restore": true, "insecure-registries": ["${seat:container-image-registry:reach}"], "log-driver": "json-file", "log-opts": {"max-size": "${config:log-max-size}", "max-file": "${config:log-max-file}"}} diff --git a/modules/docker/go.mod b/modules/docker/go.mod index 9f3955637..6cdb588a4 100644 --- a/modules/docker/go.mod +++ b/modules/docker/go.mod @@ -2,10 +2,10 @@ module docker go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/docker/go.sum b/modules/docker/go.sum index 02d6daae7..0daef11ff 100644 --- a/modules/docker/go.sum +++ b/modules/docker/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/dunst/go.mod b/modules/dunst/go.mod index 37fbba41a..8936df286 100644 --- a/modules/dunst/go.mod +++ b/modules/dunst/go.mod @@ -2,10 +2,10 @@ module dunst go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/dunst/go.sum b/modules/dunst/go.sum index 02d6daae7..0daef11ff 100644 --- a/modules/dunst/go.sum +++ b/modules/dunst/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/fail2ban/go.mod b/modules/fail2ban/go.mod index 1a2478486..57b2c63ff 100644 --- a/modules/fail2ban/go.mod +++ b/modules/fail2ban/go.mod @@ -2,10 +2,10 @@ module fail2ban go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/fail2ban/go.sum b/modules/fail2ban/go.sum index 02d6daae7..0daef11ff 100644 --- a/modules/fail2ban/go.sum +++ b/modules/fail2ban/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/feh/go.mod b/modules/feh/go.mod index 1998c0ca1..4509b99e6 100644 --- a/modules/feh/go.mod +++ b/modules/feh/go.mod @@ -2,10 +2,10 @@ module feh go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/feh/go.sum b/modules/feh/go.sum index 02d6daae7..0daef11ff 100644 --- a/modules/feh/go.sum +++ b/modules/feh/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/flatpak/go.mod b/modules/flatpak/go.mod index ba6895082..bc7284a3e 100644 --- a/modules/flatpak/go.mod +++ b/modules/flatpak/go.mod @@ -2,10 +2,10 @@ module flatpak go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/flatpak/go.sum b/modules/flatpak/go.sum index 02d6daae7..0daef11ff 100644 --- a/modules/flatpak/go.sum +++ b/modules/flatpak/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/fonts/go.mod b/modules/fonts/go.mod index 918070bf1..b96290456 100644 --- a/modules/fonts/go.mod +++ b/modules/fonts/go.mod @@ -2,10 +2,10 @@ module fonts go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/fonts/go.sum b/modules/fonts/go.sum index 02d6daae7..0daef11ff 100644 --- a/modules/fonts/go.sum +++ b/modules/fonts/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/forticlient/go.mod b/modules/forticlient/go.mod index 4e5ec2dab..1c1a6cfeb 100644 --- a/modules/forticlient/go.mod +++ b/modules/forticlient/go.mod @@ -2,10 +2,10 @@ module forticlient go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/forticlient/go.sum b/modules/forticlient/go.sum index 02d6daae7..0daef11ff 100644 --- a/modules/forticlient/go.sum +++ b/modules/forticlient/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/gitea/README.md b/modules/gitea/README.md index c6fe87c52..de98b90ed 100644 --- a/modules/gitea/README.md +++ b/modules/gitea/README.md @@ -1,8 +1,8 @@ # gitea The forge, and the mesh's npm package registry: it claims the `git` and `npm-package-registry` seats. -Its tools, its events and the provisioner of registry accounts are the TypeScript bundle (`index.ts`, -`tools/`, `provisioner/`). This README covers the two Go bundles beside it. +Its tools and its events are the TypeScript bundle (`index.ts`, +`tools/`). This README covers the two Go bundles beside it. **What a registry consumer is told** (`serves.npm-package-registry`): where the registry answers on the private network (`scheme`, `port`, `npm-path`), and its `public-root`, the address the forge writes every @@ -11,6 +11,13 @@ tarball at: `https://` and the forge's route's composed public name, under the n locked tarball only from the registry it is configured with, so a consumer that installs from lock files trusts that root. On a machine with no public domain the key is left out, and the plan says so. +**Both package provisions carry no credential** (`credential.form` none, design 51 *Publishing*): reads of the +organisation's packages are anonymous, and every write goes through the seats' verbs, so no consumer is given an +account of the forge. The registry accounts the TypeScript pair provisioner made before (`mesh__`) +and its two teams `packages` and `readers` are removed by `cmd/agent-teams` at each of its runs (ADR 0605 §5 revokes the +npm write token): both teams go, and each account of that form whose only teams they are; one the forge refuses to +delete because it owns something keeps its data and has its login prohibited. + ## Announcing pull requests to the merge check (novox/hq to-be 45 §9, issue 347) Every new head of an open pull request is announced as `pull.updated`, once, and the controller has it checked. @@ -91,7 +98,7 @@ is read once and searched in memory; a binary file is passed over, a file over 4 `skipped`, and at most `max` matches (default 100) are answered, with `truncated` when more were found. A search that reaches the call's deadline stops there and answers what it found, with `stopped_at_deadline`. -It reaches the forge through the same HTTP client as the npm-registry bundle (`internal/forgehttp`), as the +It reaches the forge through the same HTTP client as the package-registry bundle (`internal/forgehttp`), as the tools' own account (novox/hq ADR 0365 §2, issue 469): the token the TypeScript bundle mints for `mesh-tools` and keeps (`MESH_GITEA_TOKEN_FILE`), sent only when its scopes line names that account, never the admin login. One tool call takes at most 50 seconds. @@ -137,7 +144,8 @@ teams exist and who is in them is the operator's, and it is one trusted config v whenever that file changes. For each team it makes `agent-read-` in the organisation of the first repository's owner, with permission read and no other right, its repositories exactly the listed ones and its members exactly the listed accounts (an account that does not exist yet is skipped and said). A team named `agent-read-*` that has left -the value is emptied, never deleted. A team without that prefix is never touched. There are no verbs: a change is a +the value is emptied, never deleted. A team without that prefix is never touched, but the two the module made once +and declares no more, `packages` and `readers` (above). There are no verbs: a change is a change of the value. It says each change in one line on stdout, and never a secret. `go test ./cmd/agent-teams` runs it against a fake forge. @@ -181,16 +189,30 @@ No tool creates in the organisation: `gitea_create_repo` creates under the tools keeps `can_create_org_repo` false and its token has no `write:organization` (ADR 0428 §8). `go test ./cmd/repositories` runs it against a fake forge. +## The two package seats (novox/hq design 51, ADR 0605, ADR 0611) + +The forge module holds `npm-package-registry` and `go-package-registry`, and the Go bundle `cmd/package-registry` +serves both seats' verbs as tools `.`: `publish` and `publish-part` (a package's one file in chunks into +the module's own object store `incoming`, its transfers in the state `transfers`; mesh-sdk's seat-neutral receiving +side), `versions` and `remove` (a tagged version, by the builder). Before anything is uploaded the holder asks the +builder whether it numbered the version with that digest; a package goes up as npm's publish sends it, a Go module's +zip to the Go upload. Every outcome is said on the seat's events (`published`, `refused`, `removed`), a Go package's +with its module path. Who may call each verb is the trusted config value `callers` (empty: design 51's list). + ## The package registry's retention (novox/hq ADR 0251 §4, to-be 51) Every publish adds a version to the registry, and until this nothing removed one. The Go bundle -`cmd/npm-registry` says which versions each package holds, what keeps each one, and — asked with a why — +`cmd/package-registry` says which versions each package holds, what keeps each one, and — asked with a why — deletes the versions nothing keeps. | tool | | what | |---|---|---| | `npm_packages` | r | every package of the registry's owner, each version newest first with its publish time, its size and why it is kept | | `npm_retention` | a | the versions retention would delete, and the bytes. **A dry run unless `dry_run` is false**; a real run needs `why` | +| `go_packages` | r | every Go module of the registry's owner (the Go seat's), each version newest first with its publish time, its size and why it is kept: a `go.mod` on a default branch requires it, or it is among the newest `keep` | +| `go_retention` | a | the Go module versions retention would delete, and the bytes; as `npm_retention`, and a `go.mod` that could not be read stops a real run. Each deletion says `removed` on the Go seat | +| `npm_incoming` | a | the npm seat's open transfers in the module's object store `incoming`, its fill and the cap its binding carries; with `drop` and `why`, drops one by hand. A transfer with no chunk for 24 hours is dropped on its own, when its key in the state `idle` ends | +| `go_incoming` | a | the same for the Go seat | **A version is kept when:** @@ -223,11 +245,18 @@ version already gone counts as deleted. ### How it reaches the forge -Over the forge's HTTP interface on the machine (`MESH_GITEA_URL`), as the admin account the vault -delivered (`MESH_GITEA_ADMIN_USER`, the password in `MESH_GITEA_ADMIN_PASSWORD_FILE` — the same file -the TypeScript bundle mints its token with), by basic authentication: this bundle mints no token and -keeps nothing. The password is read per call and never logged, answered or put in an error. The -registry's owner is `MESH_NPM_OWNER`, the owner in the seat's `npm-path`. +Over the forge's HTTP interface on the machine (`MESH_GITEA_URL`), never as the admin account (design 51: each +holder keeps its write side as an own secret beside a read-only tool token). It **reads** as the tools' account, +with the token the TypeScript bundle keeps for it (`MESH_GITEA_TOKEN_FILE`, scopes including `read:package`), and +**writes** (a publish, a remove, retention's deletion) as the packages account `mesh-packages` +(`MESH_GITEA_PACKAGES_USER`), with its own token (`MESH_GITEA_PACKAGES_TOKEN_FILE`, the one scope `write:package`). +That account is plain, a member of the team `mesh-packages` that writes the organisation's packages and nothing else; +the watcher makes it with the administrator's login and mints and keeps its token at its start (`packages-account.ts`). +No timer looks at it after that: when the forge refuses a write (401) this bundle raises the finding +`packages-token-refused` (key `seat.gitea.packages-token-refused_write` in the module's `findings` state), the watcher, +watching that key, mints the token afresh and clears it; a write the forge takes clears it too. This bundle mints nothing; a token is +read per call and never logged, answered or put in an error. The registry's owner is `MESH_NPM_OWNER`, the owner in +the seat's `npm-path`. Calls to the forge run eight at a time, and one tool call reads for at most 50 seconds, inside a module's 60-second ask. @@ -347,5 +376,4 @@ Each directory or archive this module declares says how it treats a file it did - `${dir:mesh-state}/state`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2). - `/var/lib/mesh/forge-patches`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2). - `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2). -- `${dir:grants}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2). - `${dir:data}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2). diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 5f80c6def..8a8079a89 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -207,7 +207,8 @@ export class GiteaClient { /** * Build from the module's resolved environment. The URL comes from MESH_GITEA_URL (the mesh's own * name), falling back to the bare GITEA_URL and to the forge's loopback port. The token, in order: - * one configured in the environment (MESH_GITEA_TOKEN / GITEA_TOKEN), which wins — never a setting, which is + * one configured in the environment for a hand-run instance (GITEA_TOKEN; nothing on the mesh sets it, so no + * MESH_* word names it: checks/environment), which wins — never a setting, which is * ignored and said (hq issue 342); else * one the module mints for itself with the admin account the vault delivered and keeps in its own * state (token.ts; hq issue 100). Throws only when neither is possible, naming what is missing, @@ -217,7 +218,7 @@ export class GiteaClient { const cfg = meshConfig(env.MESH_GITEA_CONFIG_FILE); const url = cfg.url ?? env.MESH_GITEA_URL ?? env.GITEA_URL ?? `http://127.0.0.1:${env.GITEA_PORT ?? "3000"}`; ignoreSecretSetting(cfg, "token"); - const configured = env.MESH_GITEA_TOKEN ?? env.GITEA_TOKEN; + const configured = env.GITEA_TOKEN; if (configured) return new GiteaClient(url, new ConfiguredToken(configured)); return new GiteaClient(url, MintedToken.fromEnv(url, env)); } diff --git a/modules/gitea/cmd/agent-teams/main.go b/modules/gitea/cmd/agent-teams/main.go index eb92207c4..a42d5c8a9 100644 --- a/modules/gitea/cmd/agent-teams/main.go +++ b/modules/gitea/cmd/agent-teams/main.go @@ -2,7 +2,9 @@ // runtime runs once when the module starts and again whenever the module's config value `agent-read-teams` changes: // it reads the file the mesh renders from that value, and makes the forge's teams named agent-read-* match it. Each // team reads only; its repositories and its members (agent accounts, agent-) are exactly the value's. It -// touches nothing outside the value: no team without the prefix, no repository or account of another kind. +// touches nothing else of the value's kind: no team without the prefix, no repository or account of another kind. Beside +// the value it removes what the module made once and declares no more (retire.go): the npm pair's teams packages and +// readers and the accounts in them alone. // // It says each change on stdout in one line, and never a secret. package main diff --git a/modules/gitea/cmd/agent-teams/retire.go b/modules/gitea/cmd/agent-teams/retire.go new file mode 100644 index 000000000..0ed7b2c8c --- /dev/null +++ b/modules/gitea/cmd/agent-teams/retire.go @@ -0,0 +1,114 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "slices" + "sort" + "strings" + + "gitea/internal/forgehttp" +) + +// The forge accounts and teams the module made once and declares no more (novox/hq ADR 0605 §5: no build module holds +// a registry's write credential; the npm write token is revoked). The npm pair provisioner, removed when the package +// seats came, made each consumer of npm-package-registry an account under the mesh's derived login +// (mesh__) and put it in the organisation's team packages (read and write) or readers (read only). +// Reads of the packages are anonymous and every write goes through the seats, so both teams go, and each account of +// that form in no team but them goes with them. Only what the provisioner made goes: a team of those names in its +// organisation (RetiredOrg) is the provisioner's when every member of it is an account of the provisioner's form and +// it has one at least, since the provisioner put its own accounts in them alone and nobody else ever made one. A team +// a person makes later under such a name holds no such account (they are gone with the first run), so it stays, and +// so does a team of those names with a person in it or with no member. Nothing else is touched: a team of another +// name or organisation, an account of another form or one still in a team the module does not retire stays as it is. +var RetiredTeams = []string{"packages", "readers"} + +// RetiredOrg is the organisation the npm pair provisioner made its teams in (its ORG, the npm registry's owner). +const RetiredOrg = "novox" + +// retiredAccount is the login form the npm pair provisioner made its accounts under. +var retiredAccount = regexp.MustCompile(`^mesh_[a-z0-9-]+_[a-z0-9_]+$`) + +// Retire removes the retired teams in every organisation, and each account of the pair's form whose only teams they +// are. An account the forge refuses to delete (it owns a repository or an organisation, so a delete would take what +// it owns) has its login prohibited instead, said, so its password no longer reaches anything. Each removal is said +// in one line; a run with nothing left to retire says nothing. +func (s *Syncer) Retire(ctx context.Context, orgs []string) error { + teamsOf := map[string][]string{} // login -> every team it is in, as org/team + type retired struct { + org string + team forgeTeam + } + var gone []retired + retiring := map[string]bool{} // org/team + for _, org := range orgs { + teams, err := s.teams(ctx, org) + if err != nil { + return err + } + for _, t := range teams { + var members []forgeMember + if err := s.list(ctx, fmt.Sprintf("/api/v1/teams/%d/members", t.ID), &members); err != nil { + return fmt.Errorf("the members of %s in %s could not be read, so nothing is retired: %w", t.Name, org, err) + } + for _, m := range members { + teamsOf[m.Login] = append(teamsOf[m.Login], org+"/"+t.Name) + } + made := len(members) > 0 && !slices.ContainsFunc(members, func(m forgeMember) bool { return !retiredAccount.MatchString(m.Login) }) + if org == RetiredOrg && slices.Contains(RetiredTeams, t.Name) && made { + gone = append(gone, retired{org, t}) + retiring[org+"/"+t.Name] = true + } + } + } + isRetired := func(orgTeam string) bool { return retiring[orgTeam] } + var accounts []string + for login, in := range teamsOf { + if retiredAccount.MatchString(login) && slices.ContainsFunc(in, isRetired) && !slices.ContainsFunc(in, func(t string) bool { return !isRetired(t) }) { + accounts = append(accounts, login) + } + } + sort.Strings(accounts) + var errs []string + for _, a := range accounts { + if err := s.removeAccount(ctx, a); err != nil { + errs = append(errs, err.Error()) + } + } + for _, r := range gone { + if err := s.Forge.SendJSON(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/teams/%d", r.team.ID), nil, nil); err != nil && !errors.Is(err, forgehttp.ErrNotFound) { + errs = append(errs, fmt.Sprintf("the retired team %s in %s could not be removed: %v", r.team.Name, r.org, err)) + continue + } + s.say("removed the retired team %s in %s (the npm pair's, ADR 0605 §5)", r.team.Name, r.org) + } + if len(errs) > 0 { + return errors.New(strings.Join(errs, "; ")) + } + return nil +} + +// removeAccount deletes a retired account, never purging what it owns; one the forge refuses to delete has its login +// prohibited instead. +func (s *Syncer) removeAccount(ctx context.Context, login string) error { + err := s.Forge.SendJSON(ctx, http.MethodDelete, "/api/v1/admin/users/"+url.PathEscape(login), nil, nil) + if err == nil || errors.Is(err, forgehttp.ErrNotFound) { + s.say("removed the retired account %s (the npm pair's, ADR 0605 §5)", login) + return nil + } + var se *forgehttp.StatusError + if !errors.As(err, &se) || se.Status != http.StatusUnprocessableEntity { + return fmt.Errorf("the retired account %s could not be removed: %w", login, err) + } + if perr := s.Forge.SendJSON(ctx, http.MethodPatch, "/api/v1/admin/users/"+url.PathEscape(login), map[string]any{ + "login_name": login, "source_id": 0, "prohibit_login": true, + }, nil); perr != nil { + return fmt.Errorf("the retired account %s could not be removed (%v) nor its login prohibited: %w", login, err, perr) + } + s.say("the retired account %s owns something, so the forge keeps it (%v): its login is prohibited instead", login, err) + return nil +} diff --git a/modules/gitea/cmd/agent-teams/teams.go b/modules/gitea/cmd/agent-teams/teams.go index 5f2a0b6e2..2e7685c2f 100644 --- a/modules/gitea/cmd/agent-teams/teams.go +++ b/modules/gitea/cmd/agent-teams/teams.go @@ -135,6 +135,10 @@ func (s *Syncer) Sync(ctx context.Context, v Value) error { } } } + if err := s.Retire(ctx, orgs); err != nil { + s.say("not retired: %v", err) + failed = append(failed, "the retired teams and accounts") + } if len(failed) > 0 { return fmt.Errorf("not applied: %s", strings.Join(failed, ", ")) } diff --git a/modules/gitea/cmd/agent-teams/teams_test.go b/modules/gitea/cmd/agent-teams/teams_test.go index 0dfe617ca..c5e746a19 100644 --- a/modules/gitea/cmd/agent-teams/teams_test.go +++ b/modules/gitea/cmd/agent-teams/teams_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "regexp" + "slices" "sort" "strconv" "strings" @@ -34,6 +35,9 @@ type fakeForge struct { users map[string]bool teams []*fTeam next int64 + // owners are accounts that own a repository: the forge refuses to delete them (422); prohibited, whose login + // was prohibited. + owners, prohibited map[string]bool } func newFake(orgs []string, users ...string) *fakeForge { @@ -122,6 +126,26 @@ func (f *fakeForge) ServeHTTP(w http.ResponseWriter, r *http.Request) { out = nil } reply(200, out) + case strings.HasPrefix(p, "/api/v1/admin/users/"): + u := strings.TrimPrefix(p, "/api/v1/admin/users/") + switch { + case !f.users[u]: + reply(404, nil) + case r.Method == http.MethodDelete && f.owners[u]: + reply(422, map[string]string{"message": "user still owns one or more repositories"}) + case r.Method == http.MethodDelete: + delete(f.users, u) + for _, t := range f.teams { + delete(t.Members, u) + } + reply(204, nil) + case r.Method == http.MethodPatch && in["prohibit_login"] == true: + if f.prohibited == nil { + f.prohibited = map[string]bool{} + } + f.prohibited[u] = true + reply(200, map[string]string{}) + } case strings.HasPrefix(p, "/api/v1/users/"): if !f.users[strings.TrimPrefix(p, "/api/v1/users/")] { reply(404, nil) @@ -155,6 +179,10 @@ func (f *fakeForge) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } reply(200, out) + case teamPath.MatchString(p) && r.Method == http.MethodDelete: + t := f.find(teamPath.FindStringSubmatch(p)[1]) + f.teams = slices.DeleteFunc(f.teams, func(x *fTeam) bool { return x == t }) + reply(204, nil) case teamPath.MatchString(p): t := f.find(teamPath.FindStringSubmatch(p)[1]) t.Perm = in["permission"].(string) @@ -331,3 +359,52 @@ func TestReadValue(t *testing.T) { t.Fatal("an unknown field was accepted") } } + +// Security review F5 (ADR 0605 §5): the npm pair's teams packages and readers go, and each account of the pair's form +// whose only teams they are; one that owns a repository is kept with its login prohibited; an account of the pair's +// form still in another team, an account of another form and every other team stay; a second run changes nothing. +func TestTheNpmPairsTeamsAndAccountsAreRetired(t *testing.T) { + f := newFake([]string{"novox"}, "mesh_ace_mesh_build_node", "mesh_shanks_lab", "mesh_g14_agent", "mesh_novox_owner", + "mesh-packages", "agent-ace", "jschoubben", "mesh_ace_agent") + f.owners = map[string]bool{"mesh_novox_owner": true} + f.add("novox", "packages", "none", nil, []string{"mesh_ace_mesh_build_node", "mesh_g14_agent", "mesh_novox_owner"}) + f.add("novox", "readers", "none", nil, []string{"mesh_ace_mesh_build_node", "mesh_shanks_lab"}) + f.add("novox", "agents", "none", nil, []string{"mesh_g14_agent", "agent-ace"}) + // Catalog note: a team of a retired name the provisioner did not make (a person in it, or in another + // organisation) stays. + f.orgs = append(f.orgs, "other") + f.add("other", "readers", "none", nil, []string{"mesh_ace_agent"}) + f.add("novox", "mesh-packages", "none", nil, []string{"mesh-packages"}) + s, said := syncer(t, f) + if err := s.Sync(context.Background(), Value{}); err != nil { + t.Fatal(err) + } + if f.byName("novox", "packages") != nil || f.byName("novox", "readers") != nil { + t.Errorf("the retired teams stay: %+v", f.teams) + } + if f.byName("novox", "agents") == nil || f.byName("novox", "mesh-packages") == nil { + t.Errorf("a team the module did not retire was removed: %+v", f.teams) + } + for u, want := range map[string]bool{"mesh_ace_mesh_build_node": false, "mesh_shanks_lab": false, "mesh_g14_agent": true, + "mesh_novox_owner": true, "mesh-packages": true, "agent-ace": true, "jschoubben": true, "mesh_ace_agent": true} { + if f.users[u] != want { + t.Errorf("account %s kept %v, want %v", u, f.users[u], want) + } + } + if !f.prohibited["mesh_novox_owner"] || len(f.prohibited) != 1 { + t.Errorf("prohibited %v, want mesh_novox_owner alone", f.prohibited) + } + if f.byName("other", "readers") == nil { + t.Errorf("a readers team in another organisation was removed") + } + // A team a person makes after the first run under a retired name, with persons in it or none, stays. + f.add("novox", "packages", "none", nil, []string{"jschoubben"}) + f.add("novox", "readers", "none", nil, nil) + before := len(*said) + if err := s.Sync(context.Background(), Value{}); err != nil || len(*said) != before { + t.Errorf("a second run: %v, said %v; want nothing", err, (*said)[before:]) + } + if f.byName("novox", "packages") == nil || f.byName("novox", "readers") == nil || !f.users["jschoubben"] { + t.Errorf("a team a person made later under a retired name was removed: %+v", f.teams) + } +} diff --git a/modules/gitea/cmd/agent-token/main.go b/modules/gitea/cmd/agent-token/main.go index 0303b6703..c755d5a20 100644 --- a/modules/gitea/cmd/agent-token/main.go +++ b/modules/gitea/cmd/agent-token/main.go @@ -17,7 +17,7 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" ) -// ToolNames are the tools this bundle serves. The module's manifest lists no `tools` (see the npm-registry bundle +// ToolNames are the tools this bundle serves. The module's manifest lists no `tools` (see the package-registry bundle // for why); the README names this one, and a test holds the three together. var ToolNames = []string{ToolName} diff --git a/modules/gitea/cmd/forge-account-provider/builder_test.go b/modules/gitea/cmd/forge-account-provider/builder_test.go index 8897013fa..64e4231ab 100644 --- a/modules/gitea/cmd/forge-account-provider/builder_test.go +++ b/modules/gitea/cmd/forge-account-provider/builder_test.go @@ -14,7 +14,7 @@ import ( "sync" "testing" - "git.novox.be/novox/mesh-sdk/go/provider" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient/provider" "gitea/internal/forgehttp" ) diff --git a/modules/gitea/cmd/forge-account-provider/bus_test.go b/modules/gitea/cmd/forge-account-provider/bus_test.go index 7cacfe5be..22babf898 100644 --- a/modules/gitea/cmd/forge-account-provider/bus_test.go +++ b/modules/gitea/cmd/forge-account-provider/bus_test.go @@ -29,8 +29,8 @@ import ( "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" - "git.novox.be/novox/mesh-sdk/go/provider" - "git.novox.be/novox/mesh-sdk/go/provisions" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient/provider" ) const bundleVar = "MESH_FORGE_ACCOUNT_TEST_BUNDLE" @@ -301,7 +301,7 @@ func TestTheProviderWholeAnswersAMachineThatReportedItsKey(t *testing.T) { "plan": map[string]any{"provisions": []map[string]any{{"provision": "forge-account", "provider": "novox", "provider-module": "gitea", "as": "mesh_g14_ssh_client"}}}}) rt.put("mesh-provisioner.provisions", "g14.ssh-client.forge-account", - json.RawMessage(provisionKey("g14", "ssh-client", provisions.Asked, nil))) + json.RawMessage(provisionKey("g14", "ssh-client", meshprovisionerclient.Asked, nil))) rt.put("ssh-client.keys", "g14.nox", map[string]string{"node": "g14", "account": "nox", "kind": "machine", "public": pubA + " nox@g14 (mesh)", "fingerprint": "SHA256:x", "at": "2026-10-16T10:00:00Z"}) rt.start([]string{"MESH_NODE=novox", "MESH_MODULE=gitea", "MESH_SERVED_MODULE=gitea", diff --git a/modules/gitea/cmd/forge-account-provider/main.go b/modules/gitea/cmd/forge-account-provider/main.go index ddd2b624a..a9ae148ab 100644 --- a/modules/gitea/cmd/forge-account-provider/main.go +++ b/modules/gitea/cmd/forge-account-provider/main.go @@ -22,8 +22,8 @@ import ( "sync" "time" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient/provider" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/provider" "gitea/internal/forgeaccount" "gitea/internal/forgehttp" @@ -100,7 +100,7 @@ func main() { } cfg.Log = say svc := NewService(cfg) - p := svc.Provider(provider.StateBus(Module)) + p := svc.Provider(provider.StateBus(Module, provisionerModule)) ctx := context.Background() // A pass answers every consumer; one at a time, and one per change of a machine's key. var passing sync.Mutex @@ -132,3 +132,7 @@ func main() { os.Exit(1) } } + +// provisionerModule is the holder of the seat mesh-provisioner, named from this module's manifest (its `reads` of plans and +// provisions; TestTheProvisionerIsTheManifests). +const provisionerModule = "mesh-provisioner" diff --git a/modules/gitea/cmd/forge-account-provider/provisioner_names_test.go b/modules/gitea/cmd/forge-account-provider/provisioner_names_test.go new file mode 100644 index 000000000..9a3325a0d --- /dev/null +++ b/modules/gitea/cmd/forge-account-provider/provisioner_names_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "encoding/json" + "os" + "slices" + "testing" +) + +// The module holding the seat mesh-provisioner, which this provider reads the plans and provisions of, is the one its manifest's +// `reads` name: no client names a holder (novox/hq ADR 0625 §4). +func TestTheProvisionerIsTheManifests(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Reads []string `json:"reads"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, state := range []string{"plans", "provisions"} { + if !slices.Contains(m.Reads, provisionerModule+"."+state) { + t.Errorf("the provider reads %s.%s, its manifest's reads are %v", provisionerModule, state, m.Reads) + } + } +} diff --git a/modules/gitea/cmd/forge-account-provider/service.go b/modules/gitea/cmd/forge-account-provider/service.go index 0b2ad1131..cbc7f1813 100644 --- a/modules/gitea/cmd/forge-account-provider/service.go +++ b/modules/gitea/cmd/forge-account-provider/service.go @@ -12,7 +12,7 @@ import ( "sync" "time" - "git.novox.be/novox/mesh-sdk/go/provider" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient/provider" "gitea/internal/forgeaccount" "gitea/internal/forgehttp" @@ -125,7 +125,7 @@ func (s *Service) Provider(bus provider.Bus) *provider.Provider { vb := &valuesBus{Bus: bus, s: s, written: map[string][]byte{}} s.bound = vb return provider.New(provider.Options{ - Node: s.cfg.Node, Module: Module, + Node: s.cfg.Node, Module: Module, Provisioner: provisionerModule, Offers: map[string]provider.Offer{ProvisionName: {Credential: provider.NoCredential}}, Bus: vb, Make: s.Make, Gone: s.Gone, diff --git a/modules/gitea/cmd/forge-account-provider/service_test.go b/modules/gitea/cmd/forge-account-provider/service_test.go index f91aa2afc..38c32729b 100644 --- a/modules/gitea/cmd/forge-account-provider/service_test.go +++ b/modules/gitea/cmd/forge-account-provider/service_test.go @@ -16,8 +16,8 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/provider" - "git.novox.be/novox/mesh-sdk/go/provisions" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient/provider" "gitea/internal/forgehttp" ) @@ -84,7 +84,7 @@ func (b *fakeBus) entry(t *testing.T, key string) provider.Entry { // values (ADR 0570 clause 3: an entry the provider could not make is kept, failed, serving nothing; one it is not // ready to make waits and keeps serving). func (b *fakeBus) serves(key string, state ...string) bool { - want := provisions.Provided + want := meshprovisionerclient.Provided if len(state) > 0 { want = state[0] } @@ -110,29 +110,29 @@ func (b *fakeBus) bindClient(node string) { // bindPlan makes module on node bind gitea's forge-account as the seat mesh-provisioner keeps it: its plan, and the // provision key asked of gitea on novox (novox/hq ADR 0570 clauses 1 to 3). func (b *fakeBus) bindPlan(node, module, as string) { - b.set(provisions.PlansBucket, node+"."+module, []byte(fmt.Sprintf( + b.set(meshprovisionerclient.PlansBucketOf(provisionerModule), node+"."+module, []byte(fmt.Sprintf( `{"v":1,"node":%q,"module":%q,"plan":{"provisions":[{"provision":"forge-account","provider":"novox","provider-module":"gitea","as":%q}]}}`, node, module, as))) - b.set(provisions.ProvisionsBucket, node+"."+module+".forge-account", provisionKey(node, module, provisions.Asked, nil)) + b.set(meshprovisionerclient.ProvisionsBucketOf(provisionerModule), node+"."+module+".forge-account", provisionKey(node, module, meshprovisionerclient.Asked, nil)) } // retire is the operator's yes to the retiring of module's forge-account on node: its plan gone, its key retiring // with the warrant (ADR 0570 clause 5), the only way the provider lets an entry go. func (b *fakeBus) retire(node, module string) { b.mu.Lock() - delete(b.buckets[provisions.PlansBucket], node+"."+module) + delete(b.buckets[meshprovisionerclient.PlansBucketOf(provisionerModule)], node+"."+module) b.mu.Unlock() - b.set(provisions.ProvisionsBucket, node+"."+module+".forge-account", provisionKey(node, module, provisions.Retiring, - &provisions.Warrant{Ask: "ask-1", Option: "yes", By: "operator", At: time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC)})) + b.set(meshprovisionerclient.ProvisionsBucketOf(provisionerModule), node+"."+module+".forge-account", provisionKey(node, module, meshprovisionerclient.Retiring, + &meshprovisionerclient.Warrant{Ask: "ask-1", Option: "yes", By: "operator", At: time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC)})) } -func provisionKey(node, module, state string, w *provisions.Warrant) []byte { - pr := provisions.Provision{Provision: "forge-account", State: state, Warrant: w, - Consumer: provisions.End{Node: node, Module: module}, Provider: provisions.End{Node: "novox", Module: "gitea"}} - if state == provisions.Retiring { +func provisionKey(node, module, state string, w *meshprovisionerclient.Warrant) []byte { + pr := meshprovisionerclient.Provision{Provision: "forge-account", State: state, Warrant: w, + Consumer: meshprovisionerclient.End{Node: node, Module: module}, Provider: meshprovisionerclient.End{Node: "novox", Module: "gitea"}} + if state == meshprovisionerclient.Retiring { pr.Why = "no want needs it" } - raw, err := provisions.Encode(pr) + raw, err := meshprovisionerclient.Encode(pr) if err != nil { panic(err) } @@ -527,7 +527,7 @@ func TestAPassBeforeTheKeysAreLoadedRemovesAndDeletesNothing(t *testing.T) { if got := strings.Join(r.forge.titles("nox-agent"), ","); got != "mesh:g14.nox,my own" { t.Fatalf("keys = %s", got) } - if !r.bus.serves("g14.ssh-client.forge-account", provisions.Waits) { + if !r.bus.serves("g14.ssh-client.forge-account", meshprovisionerclient.Waits) { t.Fatal("the entry stopped serving before the keys were read; it waits and keeps serving") } if len(out) != 1 || !strings.Contains(out[0].Why, "not read yet") { diff --git a/modules/gitea/cmd/forge/forge.go b/modules/gitea/cmd/forge/forge.go index a28a915b0..a9d279306 100644 --- a/modules/gitea/cmd/forge/forge.go +++ b/modules/gitea/cmd/forge/forge.go @@ -12,7 +12,7 @@ import ( ) // The forge as this bundle reaches it: its own HTTP interface on the machine, as the tools' own account (novox/hq -// ADR 0365 §2; internal/forgehttp, shared with the npm-registry bundle beside it). +// ADR 0365 §2; internal/forgehttp, shared with the package-registry bundle beside it). // Forge is the forge's HTTP interface. type Forge struct { diff --git a/modules/gitea/cmd/forge/forge_test.go b/modules/gitea/cmd/forge/forge_test.go index 01883b7cc..427330873 100644 --- a/modules/gitea/cmd/forge/forge_test.go +++ b/modules/gitea/cmd/forge/forge_test.go @@ -648,7 +648,13 @@ func TestToolsAreTheOnesTheReadmeNamesAndTheManifestBuilds(t *testing.T) { t.Fatal(err) } var m struct { - Tools []string `json:"tools"` + Tools []string `json:"tools"` + Serves map[string]map[string]any `json:"serves"` + Provides []struct { + Name string `json:"name"` + Scope string `json:"scope"` + Credential map[string]string `json:"credential"` + } `json:"provides"` Build struct { Artifacts []struct { From string `json:"from"` @@ -664,28 +670,52 @@ func TestToolsAreTheOnesTheReadmeNamesAndTheManifestBuilds(t *testing.T) { if len(m.Tools) != 0 { t.Error("the manifest lists tools: its claims would offer them as both seats' verbs") } - found := false + // CAT-G2b (ADR 0346 §6, the operator 2026-11-04): the forge keeps serving the npm registry's public root, so a lock + // file naming it installs as today. CAT-G2 (design 51, E2): the Go seat's read side is the provision + // go-package-registry, mesh scope, read with no credential, offered only by this build. + if root, _ := m.Serves["npm-package-registry"]["public-root"].(string); !strings.HasPrefix(root, "https://") || !strings.HasSuffix(root, "/api/packages/novox/npm/") { + t.Errorf("npm-package-registry serves public-root %q: the public root must stay", root) + } + if g := m.Serves["go-package-registry"]; g["go-path"] != "/api/packages/novox/go" { + t.Errorf("go-package-registry serves %v, want the forge's Go proxy path", g) + } + goProvided := false + for _, p := range m.Provides { + if p.Name == "go-package-registry" { + goProvided = p.Scope == "mesh" && p.Credential["form"] == "none" + } + } + if !goProvided { + t.Error("go-package-registry is not provided at mesh scope with no credential") + } + // The tools' own account, never the admin login (novox/hq ADR 0365 §2); the package registry's bundle also writes + // as the packages account (design 51: the write side an own secret beside a read-only tool token). + wants := map[string][]string{ + "cmd/forge": {"MESH_GITEA_URL", "MESH_GITEA_TOOLS_USER", "MESH_GITEA_TOKEN_FILE"}, + "cmd/package-registry": {"MESH_GITEA_URL", "MESH_GITEA_TOOLS_USER", "MESH_GITEA_TOKEN_FILE", "MESH_GITEA_PACKAGES_USER", "MESH_GITEA_PACKAGES_TOKEN_FILE"}, + } for _, a := range m.Build.Artifacts { - if a.From == "cmd/forge" { - found = true - if len(a.Loads) != 1 || a.Loads[0] != a.Binary { - t.Errorf("the bundle loads %v, not its binary %s", a.Loads, a.Binary) + want, ok := wants[a.From] + if !ok { + continue + } + delete(wants, a.From) + if len(a.Loads) != 1 || a.Loads[0] != a.Binary { + t.Errorf("%s loads %v, not its binary %s", a.From, a.Loads, a.Binary) + } + for _, k := range want { + if a.Env[k] == "" { + t.Errorf("%s is not given %s", a.From, k) } - // The tools' own account, never the admin login (novox/hq ADR 0365 §2). - for _, k := range []string{"MESH_GITEA_URL", "MESH_GITEA_TOOLS_USER", "MESH_GITEA_TOKEN_FILE"} { - if a.Env[k] == "" { - t.Errorf("the bundle is not given %s", k) - } - } - for _, k := range []string{"MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD_FILE"} { - if a.Env[k] != "" { - t.Errorf("the bundle is given %s: it acts as the tools' account, not the administrator", k) - } + } + for _, k := range []string{"MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD_FILE"} { + if a.Env[k] != "" { + t.Errorf("%s is given %s: it acts as an account of its own, not the administrator", a.From, k) } } } - if !found { - t.Error("the manifest builds no cmd/forge bundle") + for from := range wants { + t.Errorf("the manifest builds no %s bundle", from) } } diff --git a/modules/gitea/cmd/forge/main.go b/modules/gitea/cmd/forge/main.go index 3be08aa89..89900a64e 100644 --- a/modules/gitea/cmd/forge/main.go +++ b/modules/gitea/cmd/forge/main.go @@ -1,6 +1,6 @@ // forge: the forge module's Go bundle for an agent's commit and the reads around it (novox/hq issue 375). // A process the node's runtime launches and speaks MCP over stdio to, beside the module's TypeScript bundle -// and its npm-registry bundle. gitea_commit puts one commit on a branch of its own — never the default branch, +// and its package-registry bundle. gitea_commit puts one commit on a branch of its own — never the default branch, // never one a protection rule names (novox/hq ADR 0266) — and the reads answer a commit history, a compare, a // pull request's reviews with their line comments, a directory's entries and a code search, summarised. // stdout is the protocol; it says what it says on stderr, and never the forge's credential. @@ -16,7 +16,7 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" ) -// ToolNames are the tools this bundle serves. The module's manifest lists no `tools` (see the npm-registry +// ToolNames are the tools this bundle serves. The module's manifest lists no `tools` (see the package-registry // bundle for why); the README names these, and a test holds the three together. var ToolNames = []string{"gitea_commit", "gitea_commits", "gitea_compare", "gitea_pull_reviews", "gitea_list_dir", "gitea_search_code"} diff --git a/modules/gitea/cmd/npm-registry/forge.go b/modules/gitea/cmd/package-registry/forge.go similarity index 59% rename from modules/gitea/cmd/npm-registry/forge.go rename to modules/gitea/cmd/package-registry/forge.go index c90daf9ed..f752a4116 100644 --- a/modules/gitea/cmd/npm-registry/forge.go +++ b/modules/gitea/cmd/package-registry/forge.go @@ -13,20 +13,81 @@ import ( "gitea/internal/forgehttp" ) -// The forge as this bundle reaches it: its own HTTP interface on the machine, as the forge's admin account -// (internal/forgehttp, shared with the forge bundle beside it). Basic authentication, so this bundle mints -// nothing and keeps nothing: the TypeScript bundle's token is its own. +// The forge as this bundle reaches it: its own HTTP interface on the machine (internal/forgehttp, shared with the +// forge bundle beside it), never as the admin account. It reads as the tools' account, whose token carries +// read:package, and writes (a publish, a remove, retention's deletion) as the packages account, whose token carries +// write:package (design 51: each holder keeps its write side as an own secret beside a read-only tool token). The +// TypeScript bundle mints and keeps both tokens (token.ts, packages-account.ts); this bundle mints nothing. // Forge is the forge's HTTP interface. type Forge struct { - forgehttp.Client - Owner string // the package registry's owner, as the seat serves it + forgehttp.Client // reads, as the tools' account + // Writer writes into the package registry, as the packages account; nil writes nothing. + Writer *forgehttp.Client + Owner string // the package registry's owner, as the seat serves it + // Type is the registry's package type retention reads: TypeNpm when empty, or TypeGo. + Type string + // Refused says whether the forge refused the write side's token (401), as the finding PackagesTokenRefused in the + // module's findings state: the TypeScript bundle mints a new token on that key's event and clears it (no timer + // looks at the token). Nil says nothing. + Refused func(raised bool) +} + +// PackagesTokenRefused is the finding this bundle raises when the forge refuses the packages account's token, and its +// key in the module's findings state (packages-account.ts PACKAGES_TOKEN_REFUSED watches that key). +const ( + PackagesTokenRefused = "packages-token-refused" + PackagesTokenRefusedKey = "seat.gitea.packages-token-refused_write" +) + +// wrote is a write's answer, said on Refused: a 401 raises the finding, a write the forge took clears it. +func (f *Forge) wrote(err error) error { + if f.Refused == nil { + return err + } + var status *forgehttp.StatusError + switch { + case errors.As(err, &status) && status.Status == http.StatusUnauthorized: + f.Refused(true) + case err == nil: + f.Refused(false) + } + return err +} + +// The package types the retention reads: npm packages (the npm seat) and Go modules (the Go seat). +const ( + TypeNpm = "npm" + TypeGo = "go" +) + +// typ is the package type this forge's retention reads. +func (f *Forge) typ() string { + if f.Type == "" { + return TypeNpm + } + return f.Type +} + +// As is the same forge, its retention reading the package type typ. +func (f *Forge) As(typ string) *Forge { + out := *f + out.Type = typ + return &out } // ForgeFromEnv is the forge as the manifest hands it to this bundle. func ForgeFromEnv() (*Forge, error) { c, missing := forgehttp.FromEnv() - f := &Forge{Client: c, Owner: os.Getenv("MESH_NPM_OWNER")} + w, writeMissing := forgehttp.WriterFromEnv() + for _, m := range writeMissing { + if m != "MESH_GITEA_URL" { + missing = append(missing, m) + } + } + f := &Forge{Client: c, Writer: &w, Owner: os.Getenv("MESH_NPM_OWNER"), Refused: func(raised bool) { + finding(PackagesTokenRefused, "write", "the forge refused the package seats' write token; the module mints it afresh", raised) + }} if f.Owner == "" { missing = append(missing, "MESH_NPM_OWNER") } @@ -56,12 +117,12 @@ type PackageVersion struct { const pageSize = 50 -// Packages is every npm package version of the owner, all pages. +// Packages is every package version of the owner of this forge's type, all pages. func (f *Forge) Packages(ctx context.Context) ([]PackageVersion, error) { var all []PackageVersion for page := 1; page <= 1000; page++ { var batch []PackageVersion - path := fmt.Sprintf("/api/v1/packages/%s?type=npm&page=%d&limit=%d", url.PathEscape(f.Owner), page, pageSize) + path := fmt.Sprintf("/api/v1/packages/%s?type=%s&page=%d&limit=%d", url.PathEscape(f.Owner), f.typ(), page, pageSize) if err := f.GetJSON(ctx, path, &batch); err != nil { return nil, err } @@ -74,7 +135,7 @@ func (f *Forge) Packages(ctx context.Context) ([]PackageVersion, error) { } func (f *Forge) versionPath(name, version string) string { - return fmt.Sprintf("/api/v1/packages/%s/npm/%s/%s", url.PathEscape(f.Owner), url.PathEscape(name), url.PathEscape(version)) + return f.packagePath(f.typ(), name, version) } // Size is the bytes of one version's files. @@ -104,10 +165,22 @@ func (f *Forge) DistTags(ctx context.Context, name string) (map[string]string, e return meta.DistTags, nil } -// Delete removes one version from the registry. +// write is the registry's write side, refused when the manifest handed none. +func (f *Forge) write() (*forgehttp.Client, error) { + if f.Writer == nil { + return nil, errors.New("the package registry's write side is not handed to this bundle (MESH_GITEA_PACKAGES_TOKEN_FILE); nothing was written") + } + return f.Writer, nil +} + +// Delete removes one version from the registry, as the packages account. func (f *Forge) Delete(ctx context.Context, name, version string) error { - _, err := f.do(ctx, http.MethodDelete, f.versionPath(name, version)) - return err + w, err := f.write() + if err != nil { + return err + } + _, err = w.Do(ctx, http.MethodDelete, f.versionPath(name, version), nil, maxBody) + return f.wrote(err) } // Repository is one repository on the forge, as far as reading its manifests needs. diff --git a/modules/gitea/cmd/package-registry/incoming.go b/modules/gitea/cmd/package-registry/incoming.go new file mode 100644 index 000000000..fb0984bde --- /dev/null +++ b/modules/gitea/cmd/package-registry/incoming.go @@ -0,0 +1,73 @@ +package main + +// The package seats' incoming, read and steered (design 51 *Publishing*; tools review F4): npm_incoming and +// go_incoming list a seat's open transfers, incoming's fill and the cap its binding carries, and drop one by hand. +// The holder counts against that cap, never a number of its own, and drops a transfer left with no chunk for 24 hours +// when its idle key ends (the bus's marker), never on a timer. + +import ( + "fmt" + "os" + "strings" + + "git.novox.be/novox/mesh-delivery/sdk/parts" + stdio "git.novox.be/novox/mesh-sdk/go" + + "gitea/internal/packageseats" +) + +// incomingTool is a seat's _incoming. +func incomingTool(typ, seat string, holder func() (*packageseats.Holder, error)) stdio.Tool { + str := func(args map[string]any, k string) string { s, _ := args[k].(string); return strings.TrimSpace(s) } + return stdio.Tool{ + Name: typ + "_incoming", + Description: "The " + seat + " seat's open transfers in the forge module's object store incoming (each with its " + + "opener, its parts and when it last took a chunk), incoming's fill and the cap its binding carries; a " + + "transfer with no chunk for 24 hours is dropped on its own. With drop, drops one by hand, its chunks with " + + "it (a hand act, why required). (a)", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "drop": map[string]any{"type": "string", "description": "a transfer id (the caller's own name) to drop"}, + "why": map[string]any{"type": "string", "description": "why it is dropped"}, + }}, + Run: func(args map[string]any) (any, error) { + h, err := holder() + if err != nil { + return nil, err + } + if id := str(args, "drop"); id != "" { + t, err := h.DropByHand(seat, id, stdio.CallerOf(args), str(args, "why")) + if err != nil { + return nil, err + } + fmt.Fprintf(os.Stderr, "transfer %s of %s dropped by hand by %s: %s\n", id, seat, stdio.CallerOf(args), str(args, "why")) + return map[string]any{"dropped": t.ID}, nil + } + return h.IncomingOf(seat) + }, + } +} + +// followIdle drops a transfer when its idle key ends (packageseats.Holder.Expired): the bus's marker is the event; at the +// watch's start every open transfer is looked at too, so one that ended while the bundle was down is dropped. +func followIdle(holder func() (*packageseats.Holder, error)) { + open := func() ([]string, error) { + h, err := holder() + if err != nil { + return nil, fmt.Errorf("the holder cannot be made: %w", err) + } + return parts.IDs(h.Transfers)() + } + parts.FollowIdle(packageseats.IdleState, open, func(id string) (bool, error) { + h, err := holder() + if err != nil { + return false, fmt.Errorf("transfer %s's idle bound ended, but the holder cannot be made: %w", id, err) + } + return h.Expired(id) + }, func(id string, dropped bool, err error) { + if err != nil { + fmt.Fprintln(os.Stderr, err) + return + } + fmt.Fprintf(os.Stderr, "transfer %s had no chunk for %s: dropped with its chunks\n", id, packageseats.TransferIdle) + }) +} diff --git a/modules/gitea/cmd/npm-registry/main.go b/modules/gitea/cmd/package-registry/main.go similarity index 51% rename from modules/gitea/cmd/npm-registry/main.go rename to modules/gitea/cmd/package-registry/main.go index e964d0c63..0869cfca1 100644 --- a/modules/gitea/cmd/npm-registry/main.go +++ b/modules/gitea/cmd/package-registry/main.go @@ -1,4 +1,5 @@ -// npm-registry: the forge module's Go bundle for its package registry (novox/hq ADR 0251 §4, to-be 51). +// package-registry: the forge module's Go bundle for its package registry (novox/hq ADR 0251 §4, design 51): its +// retention tools, and the verbs of the two package seats it holds (seats.go). // A process the node's runtime launches and speaks MCP over stdio to, beside the module's TypeScript // bundle. It says which versions of each npm package the registry holds, what keeps each one, and — // asked with a why — deletes the versions nothing keeps. stdout is the protocol; it says what it says on @@ -13,15 +14,21 @@ import ( "strings" stdio "git.novox.be/novox/mesh-sdk/go" + + "gitea/internal/packageseats" ) // ToolNames are the tools this bundle serves. The module's manifest lists no `tools`: its TypeScript // bundle's tools are served without one, and a claim without `serves` would offer a `tools` list to both // of the module's seats as their verbs. The README names these two, and a test holds the two together. -var ToolNames = []string{"npm_packages", "npm_retention"} +var ToolNames = []string{"go_packages", "go_retention", "go_incoming", "npm_packages", "npm_retention", "npm_incoming"} func main() { - if err := stdio.Serve("", Tools(func() (*Forge, error) { return ForgeFromEnv() })); err != nil { + forge := func() (*Forge, error) { return ForgeFromEnv() } + holder := func() (*packageseats.Holder, error) { return busHolder(forge) } + go followIdle(holder) + tools := append(Tools(forge, holder), SeatTools(holder)...) + if err := stdio.Serve("", tools); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } @@ -29,18 +36,47 @@ func main() { // Tools are the bundle's tools over the forge the function gives; asked per call, so a manifest that // does not set the environment is a refusal naming what is missing, not a bundle that will not start. -func Tools(forge func() (*Forge, error)) []stdio.Tool { +func Tools(forge func() (*Forge, error), holder func() (*packageseats.Holder, error)) []stdio.Tool { + out := append(typeTools(TypeGo, forge), incomingTool(TypeGo, packageseats.GoSeat, holder)) + return append(append(out, typeTools(TypeNpm, forge)...), incomingTool(TypeNpm, packageseats.NpmSeat, holder)) +} + +// typeTools are one package type's two tools, _packages and _retention, over the forge reading that type. +func typeTools(typ string, forgeOf func() (*Forge, error)) []stdio.Tool { + forge := func() (*Forge, error) { + f, err := forgeOf() + if err != nil { + return nil, err + } + return f.As(typ), nil + } pkg := map[string]any{"type": "string", "description": "one package, by its name (default every package)"} keep := map[string]any{"type": "integer", "description": fmt.Sprintf("how many of each package's newest versions are kept whatever names them (default %d, at least 1)", DefaultKeep)} + listed := "Every npm package in the forge's package registry, each version newest first with when it was published, " + + "its size and what keeps it: a lockfile on a repository's default branch that names it, the highest version " + + "satisfying a range a package.json there names, a dist-tag, or being among the newest `keep` of its package " + + "(novox/hq ADR 0251). Repositories and files that could not be read are named. Changes nothing. Replaces " + + "npm view and npm dist-tag ls. (r)" + deletes := "What retention would delete from the forge's package registry: every version nothing keeps — no lockfile " + + "on a repository's default branch names it, it is not the highest version satisfying any range a package.json " + + "there names, no dist-tag names it, and it is not among the newest `keep` of its package — with the bytes. " + + "A dry run unless dry_run is false; a real run needs why, and deletes nothing at all when any repository or " + + "file could not be read (novox/hq ADR 0251). Each deletion says removed on the npm seat. Replaces npm unpublish. (a)" + if typ == TypeGo { + listed = "Every Go module in the forge's package registry (the Go seat's), each version newest first with when it " + + "was published, its size and what keeps it: a go.mod on a repository's default branch that requires it, or " + + "being among the newest `keep` of its module (design 51 *Retention*). Repositories and go.mod files that " + + "could not be read are named. Changes nothing. Replaces go list -m -versions. (r)" + deletes = "What retention would delete from the forge's Go registry: every module version no go.mod on a " + + "repository's default branch requires and not among the newest `keep` of its module, with the bytes. A dry " + + "run unless dry_run is false; a real run needs why, and deletes nothing at all when any repository or go.mod " + + "could not be read. Each deletion says removed on the Go seat. (a)" + } return []stdio.Tool{ { - Name: "npm_packages", - Description: "Every npm package in the forge's package registry, each version newest first with when it was published, " + - "its size and what keeps it: a lockfile on a repository's default branch that names it, the highest version " + - "satisfying a range a package.json there names, a dist-tag, or being among the newest `keep` of its package " + - "(novox/hq ADR 0251). Repositories and files that could not be read are named. Changes nothing. Replaces " + - "npm view and npm dist-tag ls. (r)", - Input: map[string]any{"package": pkg, "keep": keep}, + Name: typ + "_packages", + Description: listed, + Input: map[string]any{"package": pkg, "keep": keep}, Run: func(args map[string]any) (any, error) { f, err := forge() if err != nil { @@ -70,12 +106,8 @@ func Tools(forge func() (*Forge, error)) []stdio.Tool { }, }, { - Name: "npm_retention", - Description: "What retention would delete from the forge's package registry: every version nothing keeps — no lockfile " + - "on a repository's default branch names it, it is not the highest version satisfying any range a package.json " + - "there names, no dist-tag names it, and it is not among the newest `keep` of its package — with the bytes. " + - "A dry run unless dry_run is false; a real run needs why, and deletes nothing at all when any repository or " + - "file could not be read (novox/hq ADR 0251). Replaces npm unpublish. (a)", + Name: typ + "_retention", + Description: deletes, Input: map[string]any{ "package": pkg, "keep": keep, diff --git a/modules/gitea/cmd/npm-registry/refs.go b/modules/gitea/cmd/package-registry/refs.go similarity index 82% rename from modules/gitea/cmd/npm-registry/refs.go rename to modules/gitea/cmd/package-registry/refs.go index 55fcfa450..744d9120f 100644 --- a/modules/gitea/cmd/npm-registry/refs.go +++ b/modules/gitea/cmd/package-registry/refs.go @@ -7,6 +7,7 @@ import ( "fmt" "path" "regexp" + "slices" "sort" "strings" ) @@ -36,18 +37,77 @@ var ( "pnpm-lock.yaml": true} ) -// interesting is whether a path is a file this reads; nothing under node_modules, which is what a -// lockfile already says and is not the repository's own. -func interesting(p string) bool { +// goModName is the file a Go module's requirements are read from. +const goModName = "go.mod" + +// interesting is whether a path is a file this reads for packages of the type typ: an npm manifest or lockfile, or +// a go.mod; nothing under node_modules or vendor, which is what a lockfile or go.mod already says and is not the +// repository's own. +func interesting(p, typ string) bool { for _, part := range strings.Split(p, "/") { - if part == "node_modules" { + if part == "node_modules" || part == "vendor" { return false } } base := path.Base(p) + if typ == TypeGo { + return base == goModName + } return manifestNames[base] || lockNames[base] } +// ReadGoMod is every registry module version a go.mod requires, or names on the right of a replace (design 51 +// *Retention*: a Go version is referenced when a go.mod on a repository's default branch requires it). A line it +// cannot read, or a block left open, is an error beside what was read: the file is then not read whole, and a real +// run stops before anything is deleted. +func ReadGoMod(content []byte, where string, known map[string]bool) ([]Pinned, error) { + var out []Pinned + pin := func(module, version string) { + if known[module] { + out = append(out, Pinned{Package: module, Version: version, Where: where}) + } + } + block := "" + for n, line := range strings.Split(string(content), "\n") { + if i := strings.Index(line, "//"); i >= 0 { + line = line[:i] + } + f := strings.Fields(line) + if len(f) == 0 { + continue + } + if block != "" { + if f[0] == ")" { + block = "" + continue + } + f = append([]string{block}, f...) + } else if len(f) == 2 && f[1] == "(" { + block = f[0] + continue + } + switch f[0] { + case "require": + if len(f) != 3 { + return out, fmt.Errorf("line %d: a require is , not %q", n+1, strings.Join(f[1:], " ")) + } + pin(f[1], f[2]) + case "replace": + arrow := slices.Index(f, "=>") + if arrow < 0 { + return out, fmt.Errorf("line %d: a replace without =>", n+1) + } + if right := f[arrow+1:]; len(right) == 2 { + pin(right[0], right[1]) + } + } + } + if block != "" { + return out, fmt.Errorf("its %s block is never closed", block) + } + return out, nil +} + var dependencyFields = []string{"dependencies", "devDependencies", "peerDependencies", "optionalDependencies"} // ReadManifest is every range a package.json names for a registry package. An `npm:` alias names the diff --git a/modules/gitea/cmd/npm-registry/refs_test.go b/modules/gitea/cmd/package-registry/refs_test.go similarity index 99% rename from modules/gitea/cmd/npm-registry/refs_test.go rename to modules/gitea/cmd/package-registry/refs_test.go index 430e30945..d6d3c897d 100644 --- a/modules/gitea/cmd/npm-registry/refs_test.go +++ b/modules/gitea/cmd/package-registry/refs_test.go @@ -85,7 +85,7 @@ func TestAPnpmLockNamesWhatItPinsInEachFormat(t *testing.T) { func TestOnlyTheRepositorysOwnManifestsAreRead(t *testing.T) { for p, want := range map[string]bool{"package.json": true, "modules/x/package-lock.json": true, "yarn.lock": true, "pnpm-lock.yaml": true, "node_modules/a/package.json": false, "README.md": false, "a/package.json.bak": false} { - if interesting(p) != want { + if interesting(p, TypeNpm) != want { t.Errorf("%s: %v", p, !want) } } diff --git a/modules/gitea/cmd/npm-registry/retention.go b/modules/gitea/cmd/package-registry/retention.go similarity index 90% rename from modules/gitea/cmd/npm-registry/retention.go rename to modules/gitea/cmd/package-registry/retention.go index 4f9915db6..d0a162051 100644 --- a/modules/gitea/cmd/npm-registry/retention.go +++ b/modules/gitea/cmd/package-registry/retention.go @@ -9,6 +9,8 @@ import ( "strings" "sync" "time" + + "gitea/internal/packageseats" ) // Retention for the package registry (novox/hq ADR 0251 §4, to-be 51): a version is kept when a @@ -48,6 +50,7 @@ type Package struct { // Survey is the registry and what the forge's repositories say of it. type Survey struct { Owner string `json:"owner"` + Type string `json:"type"` Keep int `json:"keep"` Repositories int `json:"repositories_read"` Unread []string `json:"repositories_unread,omitempty"` @@ -71,7 +74,7 @@ func survey(ctx context.Context, f *Forge, only string, keep int, sizes func(Ite } if only != "" { if _, has := byName[only]; !has { - return nil, fmt.Errorf("the registry of %s holds no npm package %q", f.Owner, only) + return nil, fmt.Errorf("the registry of %s holds no %s package %q", f.Owner, f.typ(), only) } } known := map[string]bool{} @@ -79,7 +82,7 @@ func survey(ctx context.Context, f *Forge, only string, keep int, sizes func(Ite known[name] = true } - s := &Survey{Owner: f.Owner, Keep: keep, Said: []string{}} + s := &Survey{Owner: f.Owner, Type: f.typ(), Keep: keep, Said: []string{}} wanted, pinned, err := readRepositories(ctx, f, known, s) if err != nil { return nil, err @@ -92,7 +95,10 @@ func survey(ctx context.Context, f *Forge, only string, keep int, sizes func(Ite } } sort.Strings(names) - tags := distTags(ctx, f, names) + tags := map[string]tagAnswer{} // a Go module has no dist-tags + if f.typ() == TypeNpm { + tags = distTags(ctx, f, names) + } for _, name := range names { s.Packages = append(s.Packages, decide(name, byName[name], tags[name], wanted[name], pinned[name], keep)) } @@ -167,7 +173,7 @@ func readRepositories(ctx context.Context, f *Forge, known map[string]bool, s *S var ps []Pinned var notRead, unread []string for _, p := range files { - if !interesting(p) { + if !interesting(p, f.typ()) { continue } where := r.FullName + ":" + p @@ -177,6 +183,14 @@ func readRepositories(ctx context.Context, f *Forge, known map[string]bool, s *S continue } base := path.Base(p) + if base == goModName { + got, err := ReadGoMod(content, where, known) + ps = append(ps, got...) + if err != nil { + notRead = append(notRead, fmt.Sprintf("%s: not readable (%v)", where, err)) + } + continue + } if manifestNames[base] { got, err := ReadManifest(content, where, known) if err != nil { @@ -418,12 +432,29 @@ func retention(ctx context.Context, f *Forge, only string, keep int, dryRun bool } del, cancel := context.WithTimeout(context.Background(), budget) defer cancel() + store := seatStore{forge: func() (*Forge, error) { return f, nil }} + seat := packageseats.NpmSeat + if f.typ() == TypeGo { + seat = packageseats.GoSeat + } for _, c := range p.Candidates { + digest, _, _ := store.Held(seat, c.Package, c.Version) if err := f.Delete(del, c.Package, c.Version); err != nil && !errors.Is(err, errNotFound) { p.Refused = append(p.Refused, fmt.Sprintf("%s@%s: %v", c.Package, c.Version, err)) continue } p.Deleted = append(p.Deleted, c.Package+"@"+c.Version) + // The seat says every removal (design 51 *Retention*), so the builder's entry turns removed. + // The seat's version is the builder's, without Go's leading v (forgeVersion adds it for the forge). + o := packageseats.Outcome{Name: c.Package, Version: strings.TrimPrefix(c.Version, "v"), Kind: packageseats.KindPackage, Digest: digest, Reason: packageseats.ReasonRetention} + if seat == packageseats.NpmSeat { + o.Version = c.Version + } else { + o.Module = c.Package + } + if err := say(seat, packageseats.EventRemoved, o); err != nil { + s.Said = append(s.Said, fmt.Sprintf("%s@%s is deleted but its removed could not be said: %v", c.Package, c.Version, err)) + } } s.Said = append(s.Said, fmt.Sprintf("deleted %d, refused %d, because: %s", len(p.Deleted), len(p.Refused), p.Why)) return p, nil diff --git a/modules/gitea/cmd/npm-registry/retention_test.go b/modules/gitea/cmd/package-registry/retention_test.go similarity index 73% rename from modules/gitea/cmd/npm-registry/retention_test.go rename to modules/gitea/cmd/package-registry/retention_test.go index 03917f592..e8490f2d3 100644 --- a/modules/gitea/cmd/npm-registry/retention_test.go +++ b/modules/gitea/cmd/package-registry/retention_test.go @@ -17,12 +17,14 @@ import ( "time" "gitea/internal/forgehttp" + "gitea/internal/packageseats" ) // fakeForge answers the forge's interface from maps, and records every delete. type fakeForge struct { t *testing.T versions map[string][]string // package -> versions + modules map[string][]string // Go module -> versions, listed as type go tags map[string]map[string]string // package -> dist-tags repos map[string]map[string]string // repository -> path -> content broken map[string]bool // repositories whose tree answers 500 @@ -40,11 +42,12 @@ func (f *fakeForge) serve() *Forge { if err := os.WriteFile(file, []byte(f.password+"\n"), 0o600); err != nil { f.t.Fatal(err) } - return &Forge{Client: forgehttp.Client{URL: srv.URL, User: "admin", PasswordFile: file}, Owner: "acme"} + c := forgehttp.Client{URL: srv.URL, User: "admin", PasswordFile: file} + return &Forge{Client: c, Writer: &c, Owner: "acme"} } var ( - versionFiles = regexp.MustCompile(`^/api/v1/packages/acme/npm/([^/]+)/([^/]+)(/files)?$`) + versionFiles = regexp.MustCompile(`^/api/v1/packages/acme/(?:npm|go)/([^/]+)/([^/]+)(/files)?$`) treePath = regexp.MustCompile(`^/api/v1/repos/([^/]+)/([^/]+)/git/trees/`) rawPath = regexp.MustCompile(`^/api/v1/repos/([^/]+)/([^/]+)/raw/(.+)$`) ) @@ -62,10 +65,14 @@ func (f *fakeForge) handle(w http.ResponseWriter, r *http.Request) { switch { case p == "/api/v1/packages/acme" && r.Method == http.MethodGet: var all []map[string]any - names := keys(f.versions) + listed := f.versions + if q.Get("type") == TypeGo { + listed = f.modules + } + names := keys(listed) for _, n := range names { - for i, v := range f.versions[n] { - all = append(all, map[string]any{"name": n, "version": v, "type": "npm", + for i, v := range listed[n] { + all = append(all, map[string]any{"name": n, "version": v, "type": q.Get("type"), "created_at": time.Date(2026, 1, 1+i, 0, 0, 0, 0, time.UTC)}) } } @@ -276,10 +283,19 @@ func TestARealRunWithoutWhyIsRefusedBeforeAnythingIsRead(t *testing.T) { func TestARealRunDeletesOnlyWhatNothingKeeps(t *testing.T) { f := aRegistry(t) + var removed []string + say = func(seat, event string, o packageseats.Outcome) error { + removed = append(removed, seat+" "+event+" "+o.Name+"@"+o.Version+" "+o.Reason) + return nil + } + t.Cleanup(func() { say = sayOn }) p, err := retention(context.Background(), f.serve(), "", 3, false, "the registry keeps what is named") if err != nil { t.Fatal(err) } + if sort.Strings(removed); len(removed) != 4 || removed[0] != "npm-package-registry removed @acme/sdk@0.1.1 retention" { + t.Errorf("said %v", removed) + } sort.Strings(f.deleted) if strings.Join(f.deleted, " ") != "@acme/sdk@0.1.1 @acme/sdk@0.1.2 @acme/sdk@0.2.0 @acme/sdk@0.2.1" { t.Fatalf("deleted %v", f.deleted) @@ -289,6 +305,48 @@ func TestARealRunDeletesOnlyWhatNothingKeeps(t *testing.T) { } } +// TestAVersionAGoModRequiresSurvivesGoRetention: the Go seat keeps what the npm seat keeps (design 51 *Retention*): a +// version a go.mod on a default branch requires, or names on the right of a replace, and the newest keep; the others are +// deleted and said removed on the Go seat, without Go's leading v; a go.mod that cannot be read stops a real run. +func TestAVersionAGoModRequiresSurvivesGoRetention(t *testing.T) { + f := aRegistry(t) + f.modules = map[string][]string{"git.acme/sdk": {"v0.1.0", "v0.1.1", "v0.1.2", "v0.1.3", "v0.1.4"}} + f.repos["acme/svc"] = map[string]string{ + "go.mod": "module git.acme/svc\n\ngo 1.26\n\nrequire (\n\tgit.acme/sdk v0.1.0 // indirect\n\tgolang.org/x/mod v0.20.0\n)\n", + "tools/go.mod": "module git.acme/svc/tools\nrequire git.acme/other v1.0.0\nreplace git.acme/x => git.acme/sdk v0.1.2\n", + "vendor/go.mod": "module broken\nrequire (\n", + } + var removed []string + say = func(seat, event string, o packageseats.Outcome) error { + removed = append(removed, seat+" "+event+" "+o.Module+"@"+o.Version+" "+o.Reason) + return nil + } + t.Cleanup(func() { say = sayOn }) + p, err := retention(context.Background(), f.serve().As(TypeGo), "", 1, false, "tidy the Go registry") + if err != nil { + t.Fatal(err) + } + sort.Strings(f.deleted) + if strings.Join(f.deleted, " ") != "git.acme/sdk@v0.1.1 git.acme/sdk@v0.1.3" { + t.Errorf("deleted %v, want all but the required v0.1.0, the replaced-to v0.1.2 and the newest v0.1.4", f.deleted) + } + if sort.Strings(removed); strings.Join(removed, ",") != "go-package-registry removed git.acme/sdk@0.1.1 retention,go-package-registry removed git.acme/sdk@0.1.3 retention" { + t.Errorf("said %v", removed) + } + if p.Type != TypeGo || len(p.Packages) != 1 { + t.Errorf("surveyed %s %v, want the Go registry alone", p.Type, p.Packages) + } + // A go.mod outside vendor that cannot be read stops the real run before anything is deleted. + f.deleted = nil + f.repos["acme/svc"]["cmd/go.mod"] = "module git.acme/svc/cmd\nrequire (\n\tgit.acme/sdk v0.1.1\n" + if _, err := retention(context.Background(), f.serve().As(TypeGo), "", 1, false, "tidy"); err == nil || !strings.Contains(err.Error(), "not read whole") { + t.Errorf("an unreadable go.mod = %v, want the real run refused", err) + } + if len(f.deleted) != 0 { + t.Errorf("deleted %v while a go.mod was unread", f.deleted) + } +} + func TestAnUnreadRepositoryStopsARealRunBeforeAnythingIsDeleted(t *testing.T) { f := aRegistry(t) f.broken = map[string]bool{"acme/site": true} @@ -344,12 +402,12 @@ func TestAWrongPasswordIsAFailureThatDoesNotSayThePassword(t *testing.T) { } func TestToolsAreTheOnesTheReadmeNamesAndSayWhatTheyDo(t *testing.T) { - tools := Tools(func() (*Forge, error) { return nil, fmt.Errorf("unset") }) + tools := Tools(func() (*Forge, error) { return nil, fmt.Errorf("unset") }, func() (*packageseats.Holder, error) { return nil, fmt.Errorf("unset") }) var served []string for _, tl := range tools { served = append(served, tl.Name) suffix := "(r)" - if tl.Name == "npm_retention" { + if strings.HasSuffix(tl.Name, "_retention") || strings.HasSuffix(tl.Name, "_incoming") { suffix = "(a)" } if !strings.HasSuffix(tl.Description, suffix) { @@ -373,8 +431,15 @@ func TestToolsAreTheOnesTheReadmeNamesAndSayWhatTheyDo(t *testing.T) { t.Fatal(err) } var m struct { - Tools []string `json:"tools"` - Build struct { + Tools []string `json:"tools"` + Claims []struct { + Name string `json:"name"` + Serves []string `json:"serves"` + } `json:"claims"` + Requires []any `json:"requires"` + Packages map[string]string `json:"packages"` + Invokes []string `json:"invokes"` + Build struct { Artifacts []struct { From string `json:"from"` Loads []string `json:"loads"` @@ -385,14 +450,55 @@ func TestToolsAreTheOnesTheReadmeNamesAndSayWhatTheyDo(t *testing.T) { if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } + // The builder's question is its published package, taken by its series (ADR 0634 §1). + if m.Packages["builds"] != "0.1" { + t.Errorf("packages %v: want builds of series 0.1", m.Packages) + } + // Both package seats are claimed serving the four verbs this bundle answers as ., over its own incoming + // and transfers, asking the builder's numbering (design 51 *Publishing*). + claimed := map[string]string{} + for _, c := range m.Claims { + claimed[c.Name] = strings.Join(c.Serves, ",") + } + for _, seat := range []string{packageseats.NpmSeat, packageseats.GoSeat} { + if claimed[seat] != strings.Join(packageseats.Verbs, ",") { + t.Errorf("%s is claimed serving %q, want %v", seat, claimed[seat], packageseats.Verbs) + } + } + names := map[string]bool{} + for _, tool := range SeatTools(nil) { + names[tool.Name] = true + } + if len(names) != 8 || !names[packageseats.GoSeat+".publish-part"] { + t.Errorf("the seat tools are %v", names) + } + req, _ := json.Marshal(m.Requires) + for _, want := range []string{`"name":"incoming"`, `"name":"transfers"`, `"name":"idle","options":{"key-ttl":true}`} { + if !strings.Contains(string(req), want) { + t.Errorf("the manifest does not require %s (%s)", want, req) + } + } + // findings is one local name in one form (novox/hq ADR 0595 §11): the old section state, which the TypeScript bundle + // announces readable for the condition keeper (kept.ts), never also under requires. + if strings.Contains(string(req), `"name":"findings"`) { + t.Errorf("findings is required as well as kept in state: %s", req) + } + // transfers keeps for good: a lifetime of its own would drop a transfer's key and leave its chunks in incoming. + if strings.Contains(string(req), `"name":"transfers","options"`) { + t.Errorf("transfers has options (a lifetime): %s", req) + } + // The retention asks the builder's versions; the forge's other bundles ask the indexer's modules and record a re-read. + if strings.Join(m.Invokes, ",") != "mesh-delivery-indexer.indexer_read_modules,seat:mesh-hand-acts.hand-act,mesh-delivery-builder.builder_versions" { + t.Errorf("the manifest invokes %v", m.Invokes) + } if len(m.Tools) != 0 { t.Error("the manifest lists tools: its claims would offer them as both seats' verbs") } found := false for _, a := range m.Build.Artifacts { - if a.From == "cmd/npm-registry" { + if a.From == "cmd/package-registry" { found = true - for _, k := range []string{"MESH_GITEA_URL", "MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD_FILE", "MESH_NPM_OWNER"} { + for _, k := range []string{"MESH_GITEA_URL", "MESH_GITEA_TOKEN_FILE", "MESH_GITEA_PACKAGES_TOKEN_FILE", "MESH_NPM_OWNER"} { if a.Env[k] == "" { t.Errorf("the bundle is not given %s", k) } @@ -400,7 +506,7 @@ func TestToolsAreTheOnesTheReadmeNamesAndSayWhatTheyDo(t *testing.T) { } } if !found { - t.Error("the manifest builds no cmd/npm-registry bundle") + t.Error("the manifest builds no cmd/package-registry bundle") } // Refused cleanly when the environment is missing, not a crash. if _, err := tools[0].Run(map[string]any{}); err == nil { diff --git a/modules/gitea/cmd/package-registry/seats.go b/modules/gitea/cmd/package-registry/seats.go new file mode 100644 index 000000000..cb4bce4df --- /dev/null +++ b/modules/gitea/cmd/package-registry/seats.go @@ -0,0 +1,384 @@ +package main + +// The two package seats' verbs (novox/hq design 51 *The four registry seats*, *One contract*): publish, publish-part, +// versions and remove on `npm-package-registry` and `go-package-registry`, served as tools named . and +// answered in each seat's own contract types (mesh-contracts npmpackageregistry, gopackageregistry; ADR 0620). The +// holding itself is internal/packageseats; this file binds it to the forge's package registry, the runtime's bus state +// and the seats' events. + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha1" + "crypto/sha512" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "io" + "net/http" + "net/url" + "os" + "path" + "strings" + "time" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" + meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + goreg "git.novox.be/novox/mesh-delivery/contracts/go/seats/gopackageregistry" + npmreg "git.novox.be/novox/mesh-delivery/contracts/go/seats/npmpackageregistry" + "git.novox.be/novox/mesh-delivery/sdk/parts" + stdio "git.novox.be/novox/mesh-sdk/go" + + "gitea/internal/packageseats" +) + +// seatStore is the forge's package registry as both seats store into it. +type seatStore struct{ forge func() (*Forge, error) } + +func forgeType(seat string) string { return packageseats.Seats[seat] } + +// forgeVersion is the version as the forge keeps it: a Go module's carries Go's leading v. +func forgeVersion(seat, version string) string { + if seat == packageseats.GoSeat && !strings.HasPrefix(version, "v") { + return "v" + version + } + return version +} + +func (f *Forge) packagePath(typ, name, version string) string { + return fmt.Sprintf("/api/v1/packages/%s/%s/%s/%s", url.PathEscape(f.Owner), typ, url.PathEscape(name), url.PathEscape(version)) +} + +func (s seatStore) Held(seat, name, version string) (string, bool, error) { + f, err := s.forge() + if err != nil { + return "", false, err + } + var files []struct { + SHA256 string `json:"sha256"` + } + err = f.GetJSON(context.Background(), f.packagePath(forgeType(seat), name, forgeVersion(seat, version))+"/files", &files) + switch { + case errors.Is(err, errNotFound): + return "", false, nil + case err != nil: + return "", false, err + case len(files) == 0: + return "", false, fmt.Errorf("%s@%s is listed with no file", name, version) + } + return "sha256:" + files[0].SHA256, true, nil +} + +func (s seatStore) Put(seat string, p packageseats.Publish, file []byte) (string, error) { + f, err := s.forge() + if err != nil { + return "", err + } + w, err := f.write() + if err != nil { + return "", err + } + ctx := context.Background() + if seat == packageseats.GoSeat { + module, err := goModuleOf(file, forgeVersion(seat, p.Version)) + if err != nil { + return "", err + } + _, err = w.Send(ctx, http.MethodPut, fmt.Sprintf("/api/packages/%s/go/upload", url.PathEscape(f.Owner)), file, "application/zip", maxBody) + return module, f.wrote(err) + } + doc, err := npmDocument(f.URL, f.Owner, p, file) + if err != nil { + return "", err + } + _, err = w.Do(ctx, http.MethodPut, fmt.Sprintf("/api/packages/%s/npm/%s", url.PathEscape(f.Owner), url.PathEscape(p.Name)), doc, maxBody) + return "", f.wrote(err) +} + +func (s seatStore) Versions(seat, name string) ([]packageseats.Version, error) { + f, err := s.forge() + if err != nil { + return nil, err + } + var out []packageseats.Version + for page := 1; page <= 1000; page++ { + var batch []PackageVersion + p := fmt.Sprintf("/api/v1/packages/%s?type=%s&q=%s&page=%d&limit=%d", url.PathEscape(f.Owner), forgeType(seat), url.QueryEscape(name), page, pageSize) + if err := f.GetJSON(context.Background(), p, &batch); err != nil { + return nil, err + } + for _, v := range batch { + if v.Name != name { + continue + } + d, _, err := s.Held(seat, name, v.Version) + if err != nil { + return nil, err + } + out = append(out, packageseats.Version{Version: strings.TrimPrefix(v.Version, "v"), Digest: d, Published: v.CreatedAt}) + } + if len(batch) < pageSize { + break + } + } + // Newest first, by when each was published. + for i := 1; i < len(out); i++ { + for j := i; j > 0 && out[j].Published.After(out[j-1].Published); j-- { + out[j], out[j-1] = out[j-1], out[j] + } + } + return out, nil +} + +func (s seatStore) Remove(seat, name, version string) error { + f, err := s.forge() + if err != nil { + return err + } + return f.As(forgeType(seat)).Delete(context.Background(), name, forgeVersion(seat, version)) +} + +// goModuleOf is the Go module path of a module zip whose every file sits under @/, as Go's module +// proxy protocol has it; a zip of another version is refused before the forge sees it. +func goModuleOf(file []byte, version string) (string, error) { + z, err := zip.NewReader(bytes.NewReader(file), int64(len(file))) + if err != nil { + return "", fmt.Errorf("the Go package is not a module zip: %w", err) + } + if len(z.File) == 0 { + return "", errors.New("the Go package's zip holds no file") + } + module, rest, at := strings.Cut(z.File[0].Name, "@") + v, _, ok := strings.Cut(rest, "/") + if !ok || !at || module == "" { + return "", fmt.Errorf("the Go package's zip does not hold @/: %s", z.File[0].Name) + } + if v != version { + return "", fmt.Errorf("the Go package's zip is %s, not %s", v, version) + } + return module, nil +} + +// npmDocument is the body npm's publish sends: the package.json of the tarball's package/, its dist, the tarball as +// its one attachment, and the dist-tag: the version's tag for a tagged version, so that it never becomes latest. +func npmDocument(base, owner string, p packageseats.Publish, tgz []byte) (map[string]any, error) { + manifest, err := npmManifest(tgz) + if err != nil { + return nil, err + } + if manifest["name"] != p.Name || manifest["version"] != p.Version { + return nil, fmt.Errorf("the tarball is %v@%v, not %s@%s", manifest["name"], manifest["version"], p.Name, p.Version) + } + file := path.Base(p.Name) + "-" + p.Version + ".tgz" + s1, s512 := sha1.Sum(tgz), sha512.Sum512(tgz) + manifest["_id"] = p.Name + "@" + p.Version + manifest["dist"] = map[string]any{"shasum": hex.EncodeToString(s1[:]), "integrity": "sha512-" + base64.StdEncoding.EncodeToString(s512[:]), + "tarball": fmt.Sprintf("%s/api/packages/%s/npm/%s/-/%s/%s", base, owner, p.Name, p.Version, file)} + tag := "latest" + if p.Tag != "" { + tag = p.Tag + } + return map[string]any{"_id": p.Name, "name": p.Name, "dist-tags": map[string]string{tag: p.Version}, + "versions": map[string]any{p.Version: manifest}, + "_attachments": map[string]any{file: map[string]any{"content_type": "application/octet-stream", "data": base64.StdEncoding.EncodeToString(tgz), "length": len(tgz)}}}, nil +} + +func npmManifest(tgz []byte) (map[string]any, error) { + gz, err := gzip.NewReader(bytes.NewReader(tgz)) + if err != nil { + return nil, fmt.Errorf("the npm package is not a gzipped tarball: %w", err) + } + tr := tar.NewReader(gz) + for { + h, err := tr.Next() + if err == io.EOF { + return nil, errors.New("the npm package holds no package/package.json") + } + if err != nil { + return nil, fmt.Errorf("the npm package's tarball cannot be read: %w", err) + } + if h.Name != "package/package.json" { + continue + } + var m map[string]any + if err := json.NewDecoder(io.LimitReader(tr, 1<<20)).Decode(&m); err != nil { + return nil, fmt.Errorf("the npm package's package.json is not JSON: %w", err) + } + return m, nil + } +} + +// as is v in T's shape: what crosses the bus is the seat's contract type, read or written through its JSON. +func as[T any](v any) (T, error) { + var out T + raw, err := json.Marshal(v) + if err == nil { + err = json.Unmarshal(raw, &out) + } + return out, err +} + +// outcomeBody is an outcome as the seat's contract's Outcome names it. +func outcomeBody(o packageseats.Outcome) map[string]any { + return map[string]any{"name": o.Name, "version": o.Version, "kind": o.Kind, "tag": o.Tag, "digest": o.Digest, + "fingerprint": o.Fingerprint, "commit": o.Commit, "holder": packageseats.Self, "transfer": o.Transfer, + "reference": o.Reference, "reason": o.Reason, "module": o.Module} +} + +// say says a seat's event; a test hears it instead. +var say = sayOn + +// sayOn says an event on the seat's family, in the seat's contract's Outcome. +func sayOn(seat, event string, o packageseats.Outcome) error { + var body any + var err error + if seat == packageseats.GoSeat { + body, err = as[goreg.Outcome](outcomeBody(o)) + } else { + body, err = as[npmreg.Outcome](outcomeBody(o)) + } + if err != nil { + return err + } + return stdio.SeatPublish("mesh.seat."+seat+".event."+event, body, "") +} + +func finding(kind, subject, words string, raised bool) { + key := meshconditionsclient.Key(meshconditionsclient.ScopeSeat, packageseats.Self, kind+"."+strings.NewReplacer(".", "_", "@", "_", "/", "_").Replace(subject)) + if !raised { + _ = stdio.State(meshconditions.StateFindings).Delete(key) + return + } + f := meshconditions.FindingsByKeyValue{V: meshconditionsclient.FindingVersion, Key: key, Kind: kind, Ref: key[strings.LastIndex(key, ".")+1:], + Subject: meshconditions.Subject{Scope: meshconditionsclient.ScopeSeat, ID: packageseats.Self}, Severity: meshconditionsclient.Warning, + Summary: words, Headline: words, Explanation: words, Resolved: "cleared once it no longer holds", + Source: kind, Observer: packageseats.Self, At: time.Now().UTC()} + if _, err := stdio.State(meshconditions.StateFindings).Put(key, f); err != nil { + fmt.Fprintf(os.Stderr, "the finding %s could not be written: %v\n", key, err) + } +} + +// recordAct records a hand act through the seat mesh-hand-acts alone. +func recordAct(q meshhandactsclient.Request) error { + _, err := meshhandactsclient.Recorder{Ask: stdio.Ask}.Record(q) + return err +} + +// boundCap is incoming's cap from its binding, read once per bundle. +var boundCap = parts.BusCap(packageseats.IncomingObjects) + +// busHolder is the holder over the runtime's bus state, the builder's numbering and the forge. +func busHolder(forge func() (*Forge, error)) (*packageseats.Holder, error) { + callers, err := packageseats.ConfiguredCallers() + if err != nil { + return nil, err + } + return &packageseats.Holder{Callers: callers, Objects: parts.BusObjects(packageseats.IncomingObjects), + Transfers: parts.BusTransfers[packageseats.Opening](packageseats.TransfersState), + Builder: builds.BuilderVersions{Ask: stdio.Ask}, Store: seatStore{forge}, Say: say, Finding: finding, + Cap: boundCap, Record: recordAct, Idle: parts.BusArm(packageseats.IdleState)}, nil +} + +// SeatTools are both seats' verbs as tools .. +func SeatTools(h func() (*packageseats.Holder, error)) []stdio.Tool { + return append(verbTools[npmreg.PublishInput, npmreg.PublishAnswer, npmreg.PublishPartInput, npmreg.PublishPartAnswer, + npmreg.VersionsInput, npmreg.VersionsAnswer, npmreg.RemoveInput, npmreg.RemoveAnswer](packageseats.NpmSeat, h), + verbTools[goreg.PublishInput, goreg.PublishAnswer, goreg.PublishPartInput, goreg.PublishPartAnswer, + goreg.VersionsInput, goreg.VersionsAnswer, goreg.RemoveInput, goreg.RemoveAnswer](packageseats.GoSeat, h)...) +} + +// chunk is publish-part's input as the holder reads it. +type chunk struct { + Data, Part, Transfer string + Last bool + Sequence int +} + +// verbTools are one seat's four verbs, each reading its input as the seat's contract type and answering in it. +func verbTools[PI, PA, PPI, PPA, VI, VA, RI, RA any](seat string, holder func() (*packageseats.Holder, error)) []stdio.Tool { + obj := map[string]any{"type": "object"} + run := func(do func(h *packageseats.Holder, args map[string]any) (any, error)) func(map[string]any) (any, error) { + return func(args map[string]any) (any, error) { + h, err := holder() + if err != nil { + return nil, err + } + return do(h, args) + } + } + return []stdio.Tool{ + {Name: seat + "." + packageseats.VerbPublish, Input: obj, + Description: "Open a transfer of a package's one file (ADR 0611): its name, the builder's version, kind package, its sha256 digest and the file as its one part; answers the part the forge lacks. A version held with that digest answers held and says published.", + Run: run(func(h *packageseats.Holder, args map[string]any) (any, error) { + in, err := as[PI](args) + if err != nil { + return nil, fmt.Errorf("publish: %w", err) + } + p, err := as[packageseats.Publish](in) + if err != nil { + return nil, err + } + a, err := h.Publish(seat, stdio.CallerOf(args), p) + out, _ := as[PA](map[string]any{"transfer": a.Transfer, "lacks": a.Lacks, "held": a.Ended && len(a.Lacks) == 0}) + return out, err + })}, + {Name: seat + "." + packageseats.VerbPublishPart, Input: obj, + Description: "Send one chunk (at most 760 KiB before base64) of the package's file, from the caller that opened the transfer; the last chunk ends it and the forge says published or refused.", + Run: run(func(h *packageseats.Holder, args map[string]any) (any, error) { + in, err := as[PPI](args) + if err != nil { + return nil, fmt.Errorf("publish-part: %w", err) + } + c, err := as[chunk](in) + if err != nil { + return nil, err + } + data, err := base64.StdEncoding.DecodeString(c.Data) + if err != nil { + return nil, fmt.Errorf("publish-part: data is not base64: %w", err) + } + a, err := h.PublishPart(seat, stdio.CallerOf(args), c.Transfer, c.Part, c.Sequence, c.Last, data) + out, _ := as[PPA](map[string]any{"transfer": c.Transfer, "part": c.Part, "received": c.Sequence + 1, "complete": a.Ended}) + return out, err + })}, + {Name: seat + "." + packageseats.VerbVersions, Input: obj, + Description: "The versions the seat holds of one package, newest first, each with its digest.", + Run: run(func(h *packageseats.Holder, args map[string]any) (any, error) { + in, err := as[VI](args) + if err != nil { + return nil, fmt.Errorf("versions: %w", err) + } + q, _ := as[struct{ Name string }](in) + got, err := h.Versions(seat, stdio.CallerOf(args), q.Name) + if err != nil { + return nil, err + } + list := []map[string]any{} + for _, v := range got { + list = append(list, map[string]any{"name": q.Name, "version": v.Version, "digest": v.Digest, "kind": packageseats.KindPackage}) + } + return as[VA](map[string]any{"name": q.Name, "versions": list}) + })}, + {Name: seat + "." + packageseats.VerbRemove, Input: obj, + Description: "Remove a tagged version (the builder, when its branch ended); says removed. An untagged version goes by the forge's retention alone.", + Run: run(func(h *packageseats.Holder, args map[string]any) (any, error) { + in, err := as[RI](args) + if err != nil { + return nil, fmt.Errorf("remove: %w", err) + } + r, _ := as[struct{ Name, Version, Reason string }](in) + o, err := h.Remove(seat, stdio.CallerOf(args), r.Name, r.Version, r.Reason) + if err != nil { + return nil, err + } + return as[RA](map[string]any{"removed": true, "artifact": map[string]any{"name": o.Name, "version": o.Version, "digest": o.Digest, "kind": o.Kind}}) + })}, + } +} diff --git a/modules/gitea/cmd/package-registry/seats_test.go b/modules/gitea/cmd/package-registry/seats_test.go new file mode 100644 index 000000000..6deb1ab47 --- /dev/null +++ b/modules/gitea/cmd/package-registry/seats_test.go @@ -0,0 +1,197 @@ +package main + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + "gitea/internal/forgehttp" + "gitea/internal/packageseats" +) + +func tarball(t *testing.T, manifest string) []byte { + var b bytes.Buffer + gz := gzip.NewWriter(&b) + tw := tar.NewWriter(gz) + _ = tw.WriteHeader(&tar.Header{Name: "package/package.json", Mode: 0o644, Size: int64(len(manifest))}) + _, _ = tw.Write([]byte(manifest)) + _ = tw.Close() + _ = gz.Close() + return b.Bytes() +} + +func moduleZip(t *testing.T, prefix string) []byte { + var b bytes.Buffer + z := zip.NewWriter(&b) + w, _ := z.Create(prefix + "/go.mod") + _, _ = w.Write([]byte("module x\n")) + _ = z.Close() + return b.Bytes() +} + +func sha(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToString(s[:]) } + +// registry is the forge's package registry: what was uploaded, by path, and what the files API answers. +type registry struct { + mu sync.Mutex + uploaded map[string][]byte // "npm " or "go" + held map[string][]byte // "//" -> file + deleted []string +} + +func (g *registry) handle(w http.ResponseWriter, r *http.Request) { + g.mu.Lock() + defer g.mu.Unlock() + body, _ := io.ReadAll(r.Body) + p := r.URL.EscapedPath() + // Reads come as the tools' account (read:package), writes as the packages account (write:package), never the admin. + want := "token reader" + if r.Method != http.MethodGet { + want = "token writer" + } + if got := r.Header.Get("Authorization"); got == "token stale" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } else if got != want { + http.Error(w, r.Method+" "+p+" came as "+got+", not "+want, http.StatusForbidden) + return + } + switch { + case r.Method == http.MethodPut && p == "/api/packages/acme/go/upload": + if r.Header.Get("Content-Type") != "application/zip" { + http.Error(w, "not a zip", 400) + return + } + g.uploaded["go"] = body + w.WriteHeader(http.StatusCreated) + case r.Method == http.MethodPut && strings.HasPrefix(p, "/api/packages/acme/npm/"): + g.uploaded["npm "+strings.TrimPrefix(p, "/api/packages/acme/npm/")] = body + w.WriteHeader(http.StatusCreated) + case strings.HasPrefix(p, "/api/v1/packages/acme/") && strings.HasSuffix(p, "/files"): + f, ok := g.held[strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/api/v1/packages/acme/"), "/files")] + if !ok { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode([]map[string]any{{"sha256": sha(f), "size": len(f)}}) + case r.Method == http.MethodGet && p == "/api/v1/packages/acme": + var out []map[string]any + for k := range g.held { + parts := strings.Split(k, "/") + if parts[0] == r.URL.Query().Get("type") { + out = append(out, map[string]any{"name": strings.Join(parts[1:len(parts)-1], "/"), "version": parts[len(parts)-1], "created_at": time.Unix(int64(len(parts[len(parts)-1])), 0)}) + } + } + _ = json.NewEncoder(w).Encode(out) + case r.Method == http.MethodDelete: + g.deleted = append(g.deleted, strings.TrimPrefix(p, "/api/v1/packages/acme/")) + w.WriteHeader(http.StatusNoContent) + default: + http.Error(w, "unexpected "+r.Method+" "+p, 500) + } +} + +// **The package seats store into the forge's package registry** (design 51 *Publishing*): an npm package goes up as +// npm's publish sends it, its package.json from the tarball and its tag as its dist-tag; a Go module's zip goes up to +// the Go upload, its module path read from the zip; held, versions and remove read and delete through the forge's +// package API, a Go version with Go's leading v. +func TestThePackageSeatsStoreIntoTheForge(t *testing.T) { + g := ®istry{uploaded: map[string][]byte{}, held: map[string][]byte{}} + srv := httptest.NewServer(http.HandlerFunc(g.handle)) + defer srv.Close() + dir := t.TempDir() + kept := func(name, token, line string) string { + file := filepath.Join(dir, name) + _ = os.WriteFile(file, []byte(token+"\n"), 0o600) + _ = os.WriteFile(file+".scopes", []byte(line+"\n"), 0o600) + return file + } + reader := forgehttp.Client{URL: srv.URL, TokenFile: kept("token", "reader", "read:package,read:user for mesh-tools"), Account: "mesh-tools"} + writer := forgehttp.Client{URL: srv.URL, TokenFile: kept("packages-token", "writer", "write:package for mesh-packages"), Account: "mesh-packages"} + forge := &Forge{Client: reader, Writer: &writer, Owner: "acme"} + if _, err := (seatStore{forge: func() (*Forge, error) { return &Forge{Client: reader, Owner: "acme"}, nil }}).Put(packageseats.GoSeat, packageseats.Publish{}, nil); err == nil || + !strings.Contains(err.Error(), "write side") { + t.Errorf("a forge handed no write side wrote: %v", err) + } + s := seatStore{forge: func() (*Forge, error) { return forge, nil }} + // The forge refuses the write side's token: the finding the TypeScript bundle renews on is raised under its key, + // and the next write the forge takes clears it (no timer looks at the token). + var refused []bool + forge.Refused = func(raised bool) { refused = append(refused, raised) } + if k := meshconditionsclient.Key(meshconditionsclient.ScopeSeat, packageseats.Self, PackagesTokenRefused+".write"); k != PackagesTokenRefusedKey { + t.Errorf("the refusal's key is %s, the watcher watches %s", k, PackagesTokenRefusedKey) + } + _ = os.WriteFile(writer.TokenFile, []byte("stale\n"), 0o600) + if _, err := s.Put(packageseats.GoSeat, packageseats.Publish{Version: "0.1.15"}, moduleZip(t, "git.example/acme/sdk@v0.1.15")); err == nil || + len(refused) != 1 || !refused[0] { + t.Errorf("a write the forge refused with 401 raised no finding: %v %v", err, refused) + } + _ = os.WriteFile(writer.TokenFile, []byte("writer\n"), 0o600) + + tgz := tarball(t, `{"name":"@acme/contracts","version":"0.1.15-feat-x.2","main":"index.js"}`) + p := packageseats.Publish{Name: "@acme/contracts", Version: "0.1.15-feat-x.2", Tag: "feat-x", Kind: packageseats.KindPackage, Digest: "sha256:" + sha(tgz)} + if module, err := s.Put(packageseats.NpmSeat, p, tgz); err != nil || module != "" { + t.Fatalf("npm put: %q, %v", module, err) + } + var doc struct { + DistTags map[string]string `json:"dist-tags"` + Versions map[string]map[string]any + Attachments map[string]struct{ Data string } `json:"_attachments"` + } + if err := json.Unmarshal(g.uploaded["npm @acme%2Fcontracts"], &doc); err != nil { + t.Fatalf("npm's publish body %v: %s", err, g.uploaded) + } + att := doc.Attachments["contracts-0.1.15-feat-x.2.tgz"].Data + if doc.DistTags["feat-x"] != "0.1.15-feat-x.2" || doc.DistTags["latest"] != "" || doc.Versions["0.1.15-feat-x.2"]["main"] != "index.js" || + att != base64.StdEncoding.EncodeToString(tgz) { + t.Errorf("npm's publish body: %+v", doc) + } + p.Name = "@acme/other" + if _, err := s.Put(packageseats.NpmSeat, p, tgz); err == nil || !strings.Contains(err.Error(), "not @acme/other") { + t.Errorf("a tarball of another package was uploaded: %v", err) + } + + zipped := moduleZip(t, "git.example/acme/sdk@v0.1.15") + gp := packageseats.Publish{Name: "git.example/acme/sdk", Version: "0.1.15", Kind: packageseats.KindPackage, Digest: "sha256:" + sha(zipped)} + if module, err := s.Put(packageseats.GoSeat, gp, zipped); err != nil || module != "git.example/acme/sdk" || !bytes.Equal(g.uploaded["go"], zipped) { + t.Fatalf("go put: %q, %v", module, err) + } + gp.Version = "0.1.16" + if _, err := s.Put(packageseats.GoSeat, gp, zipped); err == nil { + t.Error("a zip of another version was uploaded") + } + + g.held["go/git.example/acme/sdk/v0.1.15"] = zipped + if d, ok, err := s.Held(packageseats.GoSeat, "git.example/acme/sdk", "0.1.15"); err != nil || !ok || d != "sha256:"+sha(zipped) { + t.Errorf("held: %s %v %v", d, ok, err) + } + if _, ok, err := s.Held(packageseats.NpmSeat, "x", "0.1.0"); ok || err != nil { + t.Errorf("a version the forge does not hold: %v %v", ok, err) + } + g.held["go/git.example/acme/sdk/v0.1.9"] = zipped + if v, err := s.Versions(packageseats.GoSeat, "git.example/acme/sdk"); err != nil || len(v) != 2 || v[0].Version != "0.1.15" { + t.Errorf("versions: %+v %v", v, err) + } + if err := s.Remove(packageseats.GoSeat, "git.example/acme/sdk", "0.1.15-feat.1"); err != nil || len(g.deleted) != 1 || + g.deleted[0] != "go/git.example%2Facme%2Fsdk/v0.1.15-feat.1" { + t.Errorf("remove: %v %v", err, g.deleted) + } + if len(refused) < 2 || refused[len(refused)-1] { + t.Errorf("a write the forge took after the refusal did not clear it: %v", refused) + } +} diff --git a/modules/gitea/cmd/npm-registry/semver.go b/modules/gitea/cmd/package-registry/semver.go similarity index 100% rename from modules/gitea/cmd/npm-registry/semver.go rename to modules/gitea/cmd/package-registry/semver.go diff --git a/modules/gitea/cmd/npm-registry/semver_test.go b/modules/gitea/cmd/package-registry/semver_test.go similarity index 100% rename from modules/gitea/cmd/npm-registry/semver_test.go rename to modules/gitea/cmd/package-registry/semver_test.go diff --git a/modules/gitea/cmd/package-registry/whole_integration_test.go b/modules/gitea/cmd/package-registry/whole_integration_test.go new file mode 100644 index 000000000..788c81c8d --- /dev/null +++ b/modules/gitea/cmd/package-registry/whole_integration_test.go @@ -0,0 +1,425 @@ +//go:build integration + +package main + +// The forge module's package seats whole against a real bus (CAT-G3; design 51 *Acceptance tests*, the holder's cases): +// the built package-registry bundle, played over its real stdin and stdout as the node's tool runtime plays it, its +// `incoming` an object store and its `transfers` and `findings` key-value buckets of a nats-server of this test alone +// (so the publish in parts runs through mesh-sdk's parts.BusObjects and BusTransfers, ruling 4 of the lead's 14:0x), the +// builder's builder_versions answered on mesh/ask, the config value `callers` on mesh/provision.get, and the forge an +// httptest server that takes reads only with the tools' token and writes only with the packages account's. +// +// A Go package the builder numbered goes up in two chunks, is uploaded whole as the packages account and says +// published on the Go seat with its module path; one the builder did not number is refused before the forge sees it, +// says refused and writes the seat's finding; versions answers what the forge holds. + +import ( + "archive/zip" + "bufio" + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-sdk/go/testbus" + + "gitea/internal/packageseats" +) + +type wire struct { + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` +} + +// player is the node's tool runtime, as far as the package seats need it, over a real bus. +type player struct { + ctx context.Context + t *testing.T + nc *nats.Conn + js jetstream.JetStream + numbered map[string]string // name@version -> the digest the builder numbered + mu sync.Mutex + in io.Writer + next int + answers map[string]chan wire +} + +func (p *player) send(m map[string]any) { + m["jsonrpc"] = "2.0" + raw, _ := json.Marshal(m) + p.mu.Lock() + defer p.mu.Unlock() + _, _ = p.in.Write(append(raw, '\n')) +} + +func (p *player) answer(id json.RawMessage, result any, err error) { + if err != nil { + p.send(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": err.Error()}}) + return + } + p.send(map[string]any{"id": id, "result": result}) +} + +func (p *player) serve(out io.Reader) { + scanner := bufio.NewScanner(out) + scanner.Buffer(make([]byte, 1<<20), 1<<26) + for scanner.Scan() { + var m wire + if json.Unmarshal(scanner.Bytes(), &m) != nil { + continue + } + if m.Method == "" { + var id string + _ = json.Unmarshal(m.ID, &id) + p.mu.Lock() + ch := p.answers[id] + p.mu.Unlock() + if ch != nil { + ch <- m + } + continue + } + go p.handle(m) + } +} + +// handle answers one call of the bundle on the runtime. +func (p *player) handle(m wire) { + var a struct { + State string `json:"state"` + Key string `json:"key"` + Value json.RawMessage `json:"value"` + Objects string `json:"objects"` + Name string `json:"name"` + Data []byte `json:"data"` + Subject string `json:"subject"` + Body json.RawMessage `json:"body"` + Kind string `json:"kind"` + TTL int `json:"ttl_seconds"` + } + _ = json.Unmarshal(m.Params, &a) + switch { + case m.Method == "mesh/ask" && a.Key == builds.BuilderVersionsAddress: + var q struct{ Name, Version, Digest string } + _ = json.Unmarshal(a.Body, &q) + d, ok := p.numbered[q.Name+"@"+q.Version] + p.answer(m.ID, map[string]any{"recorded": ok && d == q.Digest, "why": "not numbered with that digest"}, nil) + case m.Method == "mesh/provision.get" && a.Kind == "config": + p.answer(m.ID, nil, nil) // `callers` unset: design 51's list + case m.Method == "mesh/seat.publish": + p.answer(m.ID, map[string]any{}, p.nc.Publish(a.Subject, a.Body)) + case m.Method == "mesh/publish": + p.answer(m.ID, map[string]any{}, nil) + case strings.HasPrefix(m.Method, "mesh/objects."): + p.objects(m, a.Objects, a.Name, a.Data) + case strings.HasPrefix(m.Method, "mesh/state."): + p.state(m, a.State, a.Key, a.Value, a.TTL) + default: + p.answer(m.ID, nil, errors.New("not played here: "+m.Method)) + } +} + +func (p *player) objects(m wire, local, name string, data []byte) { + os, err := p.js.ObjectStore(p.ctx, "gitea_"+local) + if err != nil { + p.answer(m.ID, nil, err) + return + } + switch m.Method { + case "mesh/objects.put": + info, err := os.PutBytes(p.ctx, name, data) + if err != nil { + p.answer(m.ID, nil, err) + return + } + p.answer(m.ID, map[string]any{"name": info.Name, "size": info.Size, "digest": info.Digest}, nil) + case "mesh/objects.get": + got, err := os.GetBytes(p.ctx, name) + p.answer(m.ID, map[string]any{"info": map[string]any{"name": name, "size": len(got)}, "data": got}, err) + case "mesh/objects.delete": + err := os.Delete(p.ctx, name) + if errors.Is(err, jetstream.ErrObjectNotFound) { + err = nil + } + p.answer(m.ID, map[string]any{}, err) + case "mesh/objects.info": + st, err := os.Status(p.ctx) + if err != nil { + p.answer(m.ID, nil, err) + return + } + p.answer(m.ID, map[string]any{"bytes": st.Size(), "max_bytes": 2048 << 20}, nil) + default: + p.answer(m.ID, nil, errors.New("not played here: "+m.Method)) + } +} + +func (p *player) state(m wire, state, key string, value json.RawMessage, ttl int) { + kv, err := p.js.KeyValue(p.ctx, "gitea_"+state) + if err != nil { + p.answer(m.ID, nil, err) + return + } + switch m.Method { + case "mesh/state.get": + e, err := kv.Get(p.ctx, key) + if errors.Is(err, jetstream.ErrKeyNotFound) { + p.answer(m.ID, nil, nil) + return + } + if err != nil { + p.answer(m.ID, nil, err) + return + } + p.answer(m.ID, map[string]any{"key": e.Key(), "value": json.RawMessage(e.Value()), "revision": e.Revision()}, nil) + case "mesh/state.put": + rev, err := kv.Put(p.ctx, key, value) + p.answer(m.ID, map[string]any{"revision": rev}, err) + case "mesh/state.create": + var opts []jetstream.KVCreateOpt + if ttl > 0 { + opts = append(opts, jetstream.KeyTTL(time.Duration(ttl)*time.Second)) + } + rev, err := kv.Create(p.ctx, key, value, opts...) + p.answer(m.ID, map[string]any{"revision": rev}, err) + case "mesh/state.delete": + p.answer(m.ID, map[string]any{}, kv.Delete(p.ctx, key)) + case "mesh/state.keys": + keys, err := kv.Keys(p.ctx) + if errors.Is(err, jetstream.ErrNoKeysFound) { + keys, err = []string{}, nil + } + p.answer(m.ID, keys, err) + default: + p.answer(m.ID, nil, errors.New("not played here: "+m.Method)) + } +} + +// call is one tools/call as the runtime makes it, the caller named in _meta as the runtime proves it. +func (p *player) call(caller, tool string, args map[string]any) (json.RawMessage, error) { + p.mu.Lock() + p.next++ + id := fmt.Sprintf("call-%d", p.next) + ch := make(chan wire, 1) + p.answers[id] = ch + p.mu.Unlock() + p.send(map[string]any{"id": id, "method": "tools/call", "params": map[string]any{"name": tool, "arguments": args, + "_meta": map[string]any{"mesh/caller": caller}}}) + select { + case m := <-ch: + var r struct { + Content []struct{ Text string } `json:"content"` + IsError bool `json:"isError"` + } + if err := json.Unmarshal(m.Result, &r); err != nil || len(r.Content) == 0 { + return nil, fmt.Errorf("%s answered %s", tool, m.Result) + } + if r.IsError { + return nil, errors.New(r.Content[0].Text) + } + return json.RawMessage(r.Content[0].Text), nil + case <-time.After(30 * time.Second): + return nil, fmt.Errorf("%s did not answer", tool) + } +} + +// bigZip is a Go module zip of about 1.2 MiB that does not compress, so its file goes in two chunks. +func bigZip(t *testing.T, prefix string) []byte { + noise := make([]byte, 1200<<10) + _, _ = rand.Read(noise) + var b bytes.Buffer + z := zip.NewWriter(&b) + w, _ := z.Create(prefix + "/go.mod") + _, _ = w.Write([]byte("module " + strings.Split(prefix, "@")[0] + "\n")) + w, _ = z.CreateHeader(&zip.FileHeader{Name: prefix + "/noise.bin", Method: zip.Store}) + _, _ = w.Write(noise) + _ = z.Close() + return b.Bytes() +} + +func TestThePackageSeatsWhole(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + bin := filepath.Join(t.TempDir(), "package-registry") + build := exec.Command("go", "build", "-o", bin, ".") + build.Stderr = os.Stderr + if err := build.Run(); err != nil { + t.Fatalf("package-registry does not build: %v", err) + } + nc, err := nats.Connect(testbus.URL(t)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(nc.Close) + js, _ := jetstream.New(nc) + for _, b := range []string{"gitea_" + packageseats.TransfersState, "gitea_findings"} { + if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: b}); err != nil { + t.Fatal(err) + } + } + // idle takes keys with their own time to live (key-ttl), as the bus module makes it. + if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "gitea_" + packageseats.IdleState, LimitMarkerTTL: time.Minute}); err != nil { + t.Fatal(err) + } + if _, err := js.CreateObjectStore(ctx, jetstream.ObjectStoreConfig{Bucket: "gitea_" + packageseats.IncomingObjects}); err != nil { + t.Fatal(err) + } + heard := make(chan *nats.Msg, 8) + if _, err := nc.ChanSubscribe("mesh.seat.go-package-registry.event.>", heard); err != nil { + t.Fatal(err) + } + + // The forge: reads only as the tools' account, writes only as the packages account. + g := ®istry{uploaded: map[string][]byte{}, held: map[string][]byte{}} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + g.handle(w, r) + if r.Method == http.MethodPut && r.URL.Path == "/api/packages/acme/go/upload" { + g.mu.Lock() + g.held["go/git.example/acme/sdk/v0.1.15"] = g.uploaded["go"] + g.mu.Unlock() + } + })) + t.Cleanup(srv.Close) + dir := t.TempDir() + kept := func(name, token, line string) string { + file := filepath.Join(dir, name) + _ = os.WriteFile(file, []byte(token+"\n"), 0o600) + _ = os.WriteFile(file+".scopes", []byte(line+"\n"), 0o600) + return file + } + cmd := exec.CommandContext(ctx, bin) + cmd.Env = append(os.Environ(), "MESH_GITEA_URL="+srv.URL, "MESH_NPM_OWNER=acme", "MESH_GITEA_TOOLS_USER=mesh-tools", + "MESH_GITEA_TOKEN_FILE="+kept("token", "reader", "read:package,read:user,write:issue,write:repository for mesh-tools"), + "MESH_GITEA_PACKAGES_TOKEN_FILE="+kept("packages-token", "writer", "write:package for mesh-packages")) + cmd.Stderr = os.Stderr + in, _ := cmd.StdinPipe() + out, _ := cmd.StdoutPipe() + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = in.Close(); _ = cmd.Wait() }) + + zipped := bigZip(t, "git.example/acme/sdk@v0.1.15") + digest := "sha256:" + sha(zipped) + p := &player{ctx: ctx, t: t, nc: nc, js: js, in: in, answers: map[string]chan wire{}, + numbered: map[string]string{"git.example/acme/sdk@0.1.15": digest}} + go p.serve(out) + p.send(map[string]any{"id": "init", "method": "initialize", "params": map[string]any{}}) + + builder := "ace.mesh-build-go" + publish := func(version, digest string, size int) (string, []string) { + raw, err := p.call(builder, "go-package-registry.publish", map[string]any{"name": "git.example/acme/sdk", "version": version, + "kind": "package", "digest": digest, "transfer": "sdk-" + strings.ReplaceAll(version, ".", "-"), "parts": []map[string]any{{"digest": digest, "size": size}}}) + if err != nil { + t.Fatalf("publish %s: %v", version, err) + } + var a struct { + Transfer string `json:"transfer"` + Lacks []string `json:"lacks"` + } + _ = json.Unmarshal(raw, &a) + return a.Transfer, a.Lacks + } + send := func(transfer, digest string, file []byte) error { + const chunk = 700 << 10 + for seq, at := 0, 0; at < len(file); seq, at = seq+1, at+chunk { + end := min(at+chunk, len(file)) + if _, err := p.call(builder, "go-package-registry.publish-part", map[string]any{"transfer": transfer, "part": digest, + "sequence": seq, "last": end == len(file), "data": base64.StdEncoding.EncodeToString(file[at:end])}); err != nil { + return err + } + } + return nil + } + event := func(want string) map[string]any { + select { + case m := <-heard: + if !strings.HasSuffix(m.Subject, "."+want) { + t.Fatalf("the Go seat said %s, want %s: %s", m.Subject, want, m.Data) + } + var o map[string]any + _ = json.Unmarshal(m.Data, &o) + return o + case <-time.After(30 * time.Second): + t.Fatalf("the Go seat said no %s", want) + } + return nil + } + + // A publish naming no transfer is refused: the transfer's name is the caller's own. + if _, err := p.call(builder, "go-package-registry.publish", map[string]any{"name": "git.example/acme/sdk", "version": "0.1.15", + "kind": "package", "digest": digest, "parts": []map[string]any{{"digest": digest, "size": len(zipped)}}}); err == nil || + !strings.Contains(err.Error(), "no transfer named") { + t.Errorf("a publish naming no transfer: %v", err) + } + // A version the builder numbered: two chunks through incoming and transfers, uploaded whole as the packages account. + transfer, lacks := publish("0.1.15", digest, len(zipped)) + if transfer == "" || len(lacks) != 1 || lacks[0] != digest { + t.Fatalf("publish answered transfer %q lacks %v", transfer, lacks) + } + // The open transfer is bounded by its idle key, written through the runtime with its own time to live. + idle, _ := js.KeyValue(ctx, "gitea_"+packageseats.IdleState) + if _, err := idle.Get(ctx, packageseats.GoSeat+"_"+transfer); err != nil { + t.Errorf("the open transfer %s has no idle key: %v", transfer, err) + } + if err := send(transfer, digest, zipped); err != nil { + t.Fatal(err) + } + if o := event("published"); o["module"] != "git.example/acme/sdk" || o["reference"] != "git.example/acme/sdk@0.1.15" || o["holder"] != "gitea" { + t.Errorf("published says %v", o) + } + g.mu.Lock() + up := g.uploaded["go"] + g.mu.Unlock() + if !bytes.Equal(up, zipped) { + t.Errorf("the forge took %d bytes, not the %d published", len(up), len(zipped)) + } + transfers, _ := js.KeyValue(ctx, "gitea_"+packageseats.TransfersState) + if keys, _ := transfers.Keys(ctx); len(keys) != 0 { + t.Errorf("an ended transfer is still kept: %v", keys) + } + + // One the builder did not number: refused before the forge sees it, said, and the seat's finding written. + other := bigZip(t, "git.example/acme/sdk@v0.1.16") + od := "sha256:" + sha(other) + transfer, _ = publish("0.1.16", od, len(other)) + _ = send(transfer, od, other) + if o := event("refused"); !strings.Contains(fmt.Sprint(o["reason"]), "did not number") { + t.Errorf("refused says %v", o) + } + findings, _ := js.KeyValue(ctx, "gitea_findings") + if keys, _ := findings.Keys(ctx); len(keys) == 0 { + t.Error("a refused publish wrote no finding") + } + g.mu.Lock() + _, uploadedOther := g.held["go/git.example/acme/sdk/v0.1.16"] + g.mu.Unlock() + if uploadedOther { + t.Error("a version the builder did not number reached the forge") + } + + // Anyone reads versions; the forge was read as the tools' account. + raw, err := p.call("g14.someone", "go-package-registry.versions", map[string]any{"name": "git.example/acme/sdk"}) + if err != nil || !strings.Contains(string(raw), `"version":"0.1.15"`) || !strings.Contains(string(raw), digest) { + t.Errorf("versions answered %s, %v", raw, err) + } +} diff --git a/modules/gitea/go.mod b/modules/gitea/go.mod index 56c3ae98f..62607444b 100644 --- a/modules/gitea/go.mod +++ b/modules/gitea/go.mod @@ -3,20 +3,37 @@ module gitea go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/gopackageregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/npmpackageregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 ) require ( - github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect + github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/minio/highwayhash v1.0.4 // indirect - github.com/nats-io/jwt/v2 v2.8.1 // indirect + github.com/nats-io/jwt/v2 v2.8.2 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect golang.org/x/sys v0.48.0 // indirect - golang.org/x/time v0.15.0 // indirect + golang.org/x/time v0.16.0 // indirect +) + +require ( + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000 // indirect ) diff --git a/modules/gitea/go.sum b/modules/gitea/go.sum index 43cc4ec77..8ca93dfd0 100644 --- a/modules/gitea/go.sum +++ b/modules/gitea/go.sum @@ -1,15 +1,13 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= -github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= -github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc= +github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc= github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= @@ -23,5 +21,5 @@ golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZ golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index cbf8beddc..bb15ebc64 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -45,6 +45,9 @@ import { statesAnnouncement } from "./kept.js"; import { commitStatusTool } from "./delivery.js"; import { CHECKER_OWED, CheckStatuses, watchOwed, type OwedChange } from "./check.js"; import { state } from "@novox/mesh-sdk/state"; +import { on } from "@novox/mesh-sdk/events"; +import { broker } from "@novox/mesh-sdk/messaging"; +import { ViewFollower, unansweredFinding, viewTools, type ViewAnswer, type ViewEvent } from "./views.js"; import { announce, keepWatermark, readWatermark } from "./announce.js"; import { mergeTool } from "./merge.js"; import { GiteaAdmin, GiteaClient, movedSince } from "./client.js"; @@ -57,8 +60,9 @@ import { keepSiblings, readSiblings, siblingsFile, weighSiblings } from "./sibli import { bindInBackground, bindUntilHeld } from "./bind.js"; import { announcementOf, weighTrunks } from "./trunk.js"; import { ReadAll, readAllTools } from "./readall.js"; -import { DeliveredWrites, INDEXER_MODULES, writeTools, type ModulesChange } from "./writes.js"; +import { DeliveredWrites, INDEXER_MODULES_EVENTS, INDEXER_MODULES_TOOL, INDEXER_OWNER, writeTools, type ModulesChange } from "./writes.js"; import { TOOLS_ACCOUNT, TOOLS_ORG, meshAccounts } from "./tools-account.js"; +import { PACKAGES_TOKEN_REFUSED, keepPackagesToken, packagesToken, renewOnRefusal } from "./packages-account.js"; // Without a way to a token — configured, or mintable with the admin account (token.ts) — there is // nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints @@ -392,8 +396,11 @@ let writesOnce: DeliveredWrites | null = null; const writes = (): DeliveredWrites => (writesOnce ??= new DeliveredWrites(GiteaAdmin.fromEnv(), process.env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT, TOOLS_ORG, (l) => console.log(`[gitea] ${l}`), (type, body) => emit(type, body))); +// The first view of the indexer's modules, once the module follows it (below); gitea_first_views says where it stands. +let modulesView: { follower: ViewFollower; owner: string; tool: string; state: string } | undefined; + // gitea_events is served whether or not the forge is watched: it says so (novox/hq issue 729). -registerModuleTools("gitea", () => [eventsTool(pace), ...readAllTools(readAll), ...writeTools(writes), ...(gitea ? [commitStatusTool({ +registerModuleTools("gitea", () => [eventsTool(pace), ...readAllTools(readAll), ...writeTools(writes), ...viewTools(() => modulesView, (act) => broker().request("seat:mesh-hand-acts.hand-act", act)), ...(gitea ? [commitStatusTool({ setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status), gates, }), mergeTool(gitea, { gates })] : [])]); @@ -511,13 +518,39 @@ if (gitea) { // (writes.ts). A read not granted yet is said and asked again until it holds. try { const w = writes(); - void bindUntilHeld(INDEXER_MODULES, () => state(INDEXER_MODULES).watch((change) => w.take(change as ModulesChange))); + // ADR 0615: asked once of the indexer's read tool, then kept by its events; the indexer's state itself is not read. + const modules = new ViewFollower({ owner: INDEXER_OWNER, state: "modules", tool: INDEXER_MODULES_TOOL, + ask: (after) => broker().request(INDEXER_MODULES_TOOL, after ? { values: true, after } : { values: true }) as Promise, + take: (change) => w.take(change as ModulesChange), + // Unanswered past its bound is a finding of the module's own, which the condition keeper raises (ADR 0615 §6). + raise: async (asked, error) => { + const f = unansweredFinding("gitea", INDEXER_OWNER, "modules", INDEXER_MODULES_TOOL, asked, error, new Date()); + await state("findings").put(f.key, f.value); + }, + clear: async () => { + await state("findings").delete(unansweredFinding("gitea", INDEXER_OWNER, "modules", INDEXER_MODULES_TOOL, new Date(), "", new Date()).key); + } }); + void bindUntilHeld(INDEXER_MODULES_EVENTS, () => on(INDEXER_MODULES_EVENTS, (event) => modules.hear(event.body))); + modulesView = { follower: modules, owner: INDEXER_OWNER, tool: INDEXER_MODULES_TOOL, state: "modules" }; + void modules.start(); } catch (err) { console.error(`[gitea] the merging account is not given write on the delivered repositories: no site administrator's login — ${err instanceof Error ? err.message : err}`); } // Main takes no push and only the tools' account merges, in every repository, kept and not only when a token is // minted: at the start and every ten minutes after, so a repository made later gets it too (issue 948, ADR 0398 §2). // Only while the setting keep-main-rules is true (mainrules.ts); false, the default, leaves every repository's rule as set. + // The package seats' write token (packages-account.ts): minted at the start; after that renewed only when the Go + // bundle that writes with it says the forge refused it (the finding PACKAGES_TOKEN_REFUSED), never on a timer. + try { + const url = process.env.MESH_GITEA_URL ?? "http://127.0.0.1:3000"; + const source = packagesToken(url); + const say = (line: string) => console.log(`[gitea] ${line}`); + void keepPackagesToken(source, url).then(say, (err) => console.error(`[gitea] the package seats' write token could not be minted at the start — ${err instanceof Error ? err.message : err}`)); + const findings = state("findings"); + void bindUntilHeld("findings", () => findings.watch(renewOnRefusal(source, url, findings, say), { key: PACKAGES_TOKEN_REFUSED })); + } catch (err) { + console.error(`[gitea] the package seats cannot write — ${err instanceof Error ? err.message : err}`); + } tick(mainRulesStep(keepMainRules(), () => GiteaAdmin.fromEnv().ensureMainMerges("novox"), (line) => console.log(line)), 600_000); tick(() => pollRepos(client), 60_000); // **No timer asks the forge for pull requests or merges** (issue 1422): the forge's events announce them. One pass over diff --git a/modules/gitea/internal/forgehttp/forgehttp.go b/modules/gitea/internal/forgehttp/forgehttp.go index 3b3e8c89a..3951ce342 100644 --- a/modules/gitea/internal/forgehttp/forgehttp.go +++ b/modules/gitea/internal/forgehttp/forgehttp.go @@ -1,5 +1,5 @@ // Package forgehttp is the forge's HTTP interface as this module's Go bundles reach it (cmd/forge, -// cmd/npm-registry), on the forge's own machine. One copy for both bundles (novox/hq issue 539). +// cmd/package-registry), on the forge's own machine. One copy for both bundles (novox/hq issue 539). // // Two ways in, by what the manifest hands the bundle: // @@ -46,6 +46,29 @@ type Client struct { // ToolsAccount is the account the forge's tools act as when the manifest names none (token.ts, tools-account.ts). const ToolsAccount = "mesh-tools" +// PackagesAccount is the account the package seats write as when the manifest names none (packages-account.ts): +// its token carries write:package and nothing else (design 51: each holder keeps its write side as an own secret +// beside a read-only tool token). +const PackagesAccount = "mesh-packages" + +// WriterFromEnv is the package registry's write side as the manifest hands it to a bundle: the packages account's +// token (MESH_GITEA_PACKAGES_TOKEN_FILE, kept by the module beside the tools' token) and the names of the variables +// it left unset. Never the admin login: a bundle handed no writer's token writes nothing. +func WriterFromEnv() (Client, []string) { + c := Client{URL: strings.TrimRight(os.Getenv("MESH_GITEA_URL"), "/"), TokenFile: os.Getenv("MESH_GITEA_PACKAGES_TOKEN_FILE"), + Account: os.Getenv("MESH_GITEA_PACKAGES_USER")} + if c.Account == "" { + c.Account = PackagesAccount + } + var missing []string + for _, kv := range [][2]string{{"MESH_GITEA_URL", c.URL}, {"MESH_GITEA_PACKAGES_TOKEN_FILE", c.TokenFile}} { + if kv[1] == "" { + missing = append(missing, kv[0]) + } + } + return c, missing +} + // FromEnv is the forge as the manifest hands it to a bundle, and the names of the variables it left unset. A bundle // handed MESH_GITEA_TOKEN_FILE acts as the tools' account and needs no admin login. func FromEnv() (Client, []string) { @@ -113,7 +136,7 @@ func (c *Client) password() (string, error) { func (c *Client) token() (string, error) { raw, err := os.ReadFile(c.TokenFile) if err != nil { - return "", fmt.Errorf("the tools' token is not kept yet (the module mints it for %s at its first call): %w", c.Account, err) + return "", fmt.Errorf("the token for %s is not kept yet (the module mints it): %w", c.Account, err) } line, err := os.ReadFile(c.TokenFile + ".scopes") if err != nil || !strings.HasSuffix(strings.TrimSpace(string(line)), " for "+c.Account) { @@ -148,12 +171,21 @@ func (c *Client) authorize(req *http.Request) error { // 404 is ErrNotFound. One byte past the limit is read, so a body the limit cut is told from one that fits it // exactly: a cut body is refused, never read as if it were whole. func (c *Client) Do(ctx context.Context, method, path string, payload any, limit int64) ([]byte, error) { + if payload == nil { + return c.Send(ctx, method, path, nil, "", limit) + } + raw, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return c.Send(ctx, method, path, raw, "application/json", limit) +} + +// Send is Do with a body of its own content type, sent as it is (a package's file uploaded into the registry); +// a nil body sends none. +func (c *Client) Send(ctx context.Context, method, path string, raw []byte, contentType string, limit int64) ([]byte, error) { var body io.Reader - if payload != nil { - raw, err := json.Marshal(payload) - if err != nil { - return nil, err - } + if raw != nil { body = bytes.NewReader(raw) } req, err := http.NewRequestWithContext(ctx, method, c.URL+path, body) @@ -164,8 +196,8 @@ func (c *Client) Do(ctx context.Context, method, path string, payload any, limit return nil, err } req.Header.Set("Accept", "application/json") - if payload != nil { - req.Header.Set("Content-Type", "application/json") + if raw != nil { + req.Header.Set("Content-Type", contentType) } res, err := c.client().Do(req) if err != nil { @@ -181,8 +213,8 @@ func (c *Client) Do(ctx context.Context, method, path string, payload any, limit } switch { case res.StatusCode == http.StatusUnauthorized && c.TokenFile != "": - return nil, fmt.Errorf("the forge rejected the tools' token for %s (401): the module mints it afresh at its next call; nothing was done: %w", - method+" "+path, &StatusError{method, path, res.StatusCode, "unauthorized"}) + return nil, fmt.Errorf("the forge rejected %s's token for %s (401): the module mints it afresh; nothing was done: %w", + c.Account, method+" "+path, &StatusError{method, path, res.StatusCode, "unauthorized"}) case res.StatusCode == http.StatusNotFound: return nil, fmt.Errorf("%s %s: %w", method, path, ErrNotFound) case res.StatusCode >= 300: diff --git a/modules/gitea/internal/packageseats/holder.go b/modules/gitea/internal/packageseats/holder.go new file mode 100644 index 000000000..244f1322d --- /dev/null +++ b/modules/gitea/internal/packageseats/holder.go @@ -0,0 +1,415 @@ +// Package packageseats is the forge module's holding of the two package seats, `npm-package-registry` and +// `go-package-registry` (novox/hq design 51 *The four registry seats*, ADR 0605, ADR 0611). A build module publishes +// a package through `publish` and `publish-part`: the package's one file lands in the forge module's own object store +// `incoming`, is hashed, checked against the builder's numbering and then uploaded into the forge's package registry +// with the module's own write side. The receiving side is mesh-sdk's seat-neutral parts.Receiver and the caller list +// mesh-sdk's VerbCallers (ADR 0627 §2): this package binds both to the two seats and to the forge. +package packageseats + +import ( + "errors" + "fmt" + "strings" + "time" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" + "git.novox.be/novox/mesh-delivery/sdk/parts" + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// The seats the forge module holds. +const ( + NpmSeat = "npm-package-registry" + GoSeat = "go-package-registry" +) + +// Seats are the package seats, each with the forge's own package type behind it. +var Seats = map[string]string{NpmSeat: "npm", GoSeat: "go"} + +// The verbs both seats serve (design 51 *One contract*). +const ( + VerbPublish = "publish" + VerbPublishPart = "publish-part" + VerbVersions = "versions" + VerbRemove = "remove" +) + +// Verbs are the verbs of both seats, in order. +var Verbs = []string{VerbPublish, VerbPublishPart, VerbRemove, VerbVersions} + +// The events of both seats. +const ( + EventPublished = "published" + EventRefused = "refused" + EventRemoved = "removed" +) + +// The bounds of incoming (design 51 *Publishing*, ADR 0611): a chunk at most 1 MiB, a transfer with no chunk for 24 +// hours dropped, a finding past 80 % of the cap. The cap is the one incoming's binding carries; IncomingCap is the +// manifest's 2 GiB (a package is one file, far smaller than an image), said only where no binding is read. +const ( + MaxChunk = 1 << 20 + IncomingCap = 2 << 30 + TransferIdle = 24 * time.Hour + IncomingFullPct = 80 + IncomingObjects = "incoming" + TransfersState = "transfers" + // IdleState is the state of one key per open transfer with its own time to live (a state declared key-ttl): its + // end is the event that drops a transfer left with no chunk for TransferIdle, never a timer. + IdleState = "idle" + // KindPackage is the one kind a package seat holds. + KindPackage = "package" + // Self is the forge module, the holder every outcome names. + Self = "gitea" + // ReasonRetention is the reason of the forge's own retention removing a version nothing keeps. + ReasonRetention = "retention" +) + +// Callers is the trusted config value `callers`, read as mesh-sdk's list per verb. +type Callers = stdio.VerbCallers + +// DefaultCallers is design 51's list for the package seats: publish and publish-part the two build modules that +// publish packages; remove the builder (a tagged version whose branch ended); versions anyone on the mesh, as the +// registry's reads are anonymous. +func DefaultCallers() Callers { + builds := []string{"mesh-build-go", "mesh-build-node"} + return Callers{VerbPublish: builds, VerbPublishPart: builds, VerbRemove: {"mesh-delivery-builder"}, + VerbVersions: {stdio.AnyCaller}} +} + +// ConfiguredCallers is the config value `callers` as mesh-config resolved it; unset is DefaultCallers. +func ConfiguredCallers() (Callers, error) { + return stdio.ConfiguredVerbCallers("callers", Verbs, DefaultCallers()) +} + +// Publish is a publish as the seats' contract names it (mesh-contracts npmpackageregistry and gopackageregistry share +// one shape): what the tools bundle reads from either seat's PublishInput. +type Publish struct { + Transfer string `json:"transfer"` + Name string `json:"name"` + Version string `json:"version,omitempty"` + Tag string `json:"tag,omitempty"` + Kind string `json:"kind"` + Fingerprint string `json:"fingerprint,omitempty"` + Commit string `json:"commit,omitempty"` + Digest string `json:"digest"` + Parts []parts.Want `json:"parts,omitempty"` +} + +// Opening is what the holder keeps of a publish beside the receiving side's own fields. +type Opening struct { + Publish + Seat string `json:"seat"` +} + +// Transfer is one open publish in the state transfers. +type Transfer = parts.Transfer[Opening] + +// Outcome is the body of published, refused and removed, as the contract's Outcome names it. +type Outcome struct { + Name, Version, Kind, Tag, Digest, Fingerprint, Commit, Transfer, Reference, Reason string + // Module is the Go module path a Go package is fetched under; empty on the npm seat. + Module string +} + +// Answer is what publish and publish-part answer: the transfer, the parts lacked, and whether it ended. +type Answer struct { + Transfer string + Lacks []string + Ended bool +} + +// Version is one version a seat holds of a name. +type Version struct { + Version string + Digest string + Published time.Time +} + +// Builder answers whether the builder numbered a version with a digest (the builder package's builds.BuilderVersions). +type Builder interface { + Recorded(name, version, digest string) (ok bool, why string, err error) +} + +// Store is the forge's package registry, written with the module's own write side alone. +type Store interface { + // Held is the sha256 digest of name@version's file when the registry holds it. + Held(seat, name, version string) (digest string, ok bool, err error) + // Put uploads the package's file and answers the Go module path (empty for npm). + Put(seat string, p Publish, file []byte) (module string, err error) + // Versions is every version of name, newest first. + Versions(seat, name string) ([]Version, error) + // Remove deletes name@version. + Remove(seat, name, version string) error +} + +// Holder answers the verbs of both seats. +type Holder struct { + Callers Callers + Objects parts.Objects + Transfers parts.Transfers[Opening] + Builder Builder + Store Store + // Say says an event on a seat's family. + Say func(seat, event string, o Outcome) error + // Finding writes (raised) or clears the module's finding of a kind for a subject. + Finding func(kind, subject, words string, raised bool) + Now func() time.Time + // Cap reads incoming's cap from its binding (parts.BusCap); IncomingCap when nil or when the binding names none. + Cap func() (uint64, error) + // Record records a hand act through the seat mesh-hand-acts (meshhandactsclient.Recorder); a drop by hand is refused + // without it. + Record func(meshhandactsclient.Request) error + // Idle writes a transfer's idle key with its own time to live, once (parts.BusArm over IdleState); the receiver + // arms it on each open that leaves a transfer open. Nil arms nothing. + Idle func(key string, ttl time.Duration) error +} + +// CapNow is incoming's cap as its binding carries it. +func (h *Holder) CapNow() uint64 { return h.receiver().CapNow() } + +func (h *Holder) now() time.Time { + if h.Now != nil { + return h.Now().UTC() + } + return time.Now().UTC() +} + +// Reference is the seat's answer for a package: @ (ADR 0605 §9). +func Reference(name, version string) string { return name + "@" + version } + +// key is a transfer's key: the caller's own name under its seat, so the two seats' transfers never meet. +func key(seat, transfer string) string { return seat + "_" + transfer } + +func (h *Holder) receiver() *parts.Receiver[Opening] { + return &parts.Receiver[Opening]{Objects: h.Objects, Transfers: h.Transfers, Cap: IncomingCap, Bound: h.Cap, + MaxChunk: MaxChunk, Idle: TransferIdle, Arm: h.Idle, Full: IncomingFullPct, Now: h.now, + // The builder's numbering is asked before anything is uploaded (ADR 0605 §4). + Check: func(t Transfer) error { + ok, why, err := h.Builder.Recorded(t.Shape.Name, t.Shape.Version, t.Shape.Digest) + if err != nil { + return errors.New("builder_versions did not answer, so nothing is stored: " + err.Error()) + } + if !ok { + return errors.New("the builder did not number it: " + why) + } + return nil + }, + Store: func(t Transfer, held map[string][]byte) error { + file := held[t.Shape.Digest] + module, err := h.Store.Put(t.Shape.Seat, t.Shape.Publish, file) + if err != nil { + reason := "the forge did not take it: " + err.Error() + return errors.Join(errors.New("refused: "+reason), h.refused(t, reason)) + } + h.Finding("publish-refused", t.Shape.Name+"@"+t.Shape.Version, "", false) + h.Finding("publish-refused", IncomingObjects, "", false) + o := outcomeOf(t.Shape.Publish, "") + o.Reference, o.Module = Reference(t.Shape.Name, t.Shape.Version), module + return h.Say(t.Shape.Seat, EventPublished, o) + }, + Refuse: h.refused, + Filled: func(used, cap uint64, full bool) { + h.Finding("incoming-full", IncomingObjects, fmt.Sprintf("incoming holds %d of its %d bytes (%d %%)", used, cap, used*100/cap), full) + }} +} + +func (h *Holder) refused(t Transfer, reason string) error { + subject := t.Shape.Name + "@" + t.Shape.Version + if parts.RefusedAtCap(reason) { + subject = IncomingObjects + } + h.Finding("publish-refused", subject, reason, true) + return h.Say(t.Shape.Seat, EventRefused, outcomeOf(t.Shape.Publish, reason)) +} + +func outcomeOf(p Publish, reason string) Outcome { + return Outcome{Name: p.Name, Version: p.Version, Kind: p.Kind, Tag: p.Tag, Digest: p.Digest, Fingerprint: p.Fingerprint, + Commit: p.Commit, Transfer: p.Transfer, Reason: reason} +} + +func (h *Holder) answer(transfer string, r parts.Received) Answer { + return Answer{Transfer: transfer, Lacks: r.Lacks, Ended: r.Ended} +} + +// Publish is `publish` on seat for the proved caller: a package is one file, its digest the file's sha256. +func (h *Holder) Publish(seat, caller string, p Publish) (Answer, error) { + if err := h.Callers.Allowed(VerbPublish, caller); err != nil { + return Answer{}, err + } + if err := checkPublish(seat, p); err != nil { + return Answer{}, err + } + // A rebuild of a published version answers it held and uploads nothing; the same version with other content is + // refused, as the registry would refuse it. + if d, ok, err := h.Store.Held(seat, p.Name, p.Version); err != nil { + return Answer{}, fmt.Errorf("publish: the forge cannot say whether it holds %s@%s: %w", p.Name, p.Version, err) + } else if ok { + if d != p.Digest { + reason := fmt.Sprintf("%s@%s is published with %s, not %s", p.Name, p.Version, d, p.Digest) + return Answer{Transfer: p.Transfer, Ended: true}, h.refused(Transfer{Shape: Opening{Publish: p, Seat: seat}}, reason) + } + o := outcomeOf(p, "") + o.Reference = Reference(p.Name, p.Version) + return Answer{Transfer: p.Transfer, Ended: true}, h.Say(seat, EventPublished, o) + } + // The transfer's name is the caller's own (the seat's contract): none would open the seat's one nameless transfer, + // which key() still makes a valid id. + if strings.TrimSpace(p.Transfer) == "" { + return Answer{}, errors.New("publish: no transfer named; the caller names its own") + } + got, err := h.receiver().Open(key(seat, p.Transfer), caller, p.Parts, Opening{Publish: p, Seat: seat}) + if err != nil { + return h.answer(p.Transfer, got), fmt.Errorf("publish: %w", err) + } + return h.answer(p.Transfer, got), nil +} + +// PublishPart is `publish-part`: one chunk, from the caller that opened the transfer alone. +func (h *Holder) PublishPart(seat, caller, transfer, part string, sequence int, last bool, data []byte) (Answer, error) { + if err := h.Callers.Allowed(VerbPublishPart, caller); err != nil { + return Answer{}, err + } + got, err := h.receiver().Take(key(seat, transfer), caller, part, sequence, last, data, func(t Transfer) error { + if t.Shape.Seat != seat { + return fmt.Errorf("transfer %s is %s's, not %s's", transfer, t.Shape.Seat, seat) + } + return nil + }) + if err != nil { + return h.answer(transfer, got), fmt.Errorf("publish-part: %w", err) + } + return h.answer(transfer, got), nil +} + +// Versions is `versions`: every version the seat holds of name, newest first. +func (h *Holder) Versions(seat, caller, name string) ([]Version, error) { + if err := h.Callers.Allowed(VerbVersions, caller); err != nil { + return nil, err + } + if _, ok := Seats[seat]; !ok { + return nil, fmt.Errorf("versions: the forge holds no seat %s", seat) + } + if strings.TrimSpace(name) == "" { + return nil, errors.New("versions: no name") + } + v, err := h.Store.Versions(seat, name) + if err != nil { + return nil, fmt.Errorf("versions: the forge cannot list %s: %w", name, err) + } + return v, nil +} + +// Remove is `remove`: the builder removes a tagged version (its branch ended); an untagged version goes only by the +// forge's own retention, never by a call. A version already gone is said removed all the same, so a retry ends. +func (h *Holder) Remove(seat, caller, name, version, reason string) (Outcome, error) { + if err := h.Callers.Allowed(VerbRemove, caller); err != nil { + return Outcome{}, err + } + if _, ok := Seats[seat]; !ok { + return Outcome{}, fmt.Errorf("remove: the forge holds no seat %s", seat) + } + if strings.TrimSpace(name) == "" || strings.TrimSpace(reason) == "" { + return Outcome{}, errors.New("remove: a name and a reason are required") + } + if !strings.Contains(version, "-") { + return Outcome{}, fmt.Errorf("remove: refused: %s@%s has no tag; only a tagged version is removed, an untagged one by retention alone", name, version) + } + d, held, err := h.Store.Held(seat, name, version) + if err != nil { + return Outcome{}, fmt.Errorf("remove: the forge cannot read %s@%s: %w", name, version, err) + } + if held { + if err := h.Store.Remove(seat, name, version); err != nil { + return Outcome{}, fmt.Errorf("remove: the forge did not remove %s@%s: %w", name, version, err) + } + } + o := Outcome{Name: name, Version: version, Kind: KindPackage, Digest: d, Reason: reason} + return o, h.Say(seat, EventRemoved, o) +} + +// Retained says `removed` for a version the forge's own retention deleted (design 51 *Retention*). +func (h *Holder) Retained(seat, name, version, digest string) error { + return h.Say(seat, EventRemoved, Outcome{Name: name, Version: version, Kind: KindPackage, Digest: digest, Reason: ReasonRetention}) +} + +// Drop drops a transfer by hand. +func (h *Holder) Drop(seat, transfer string) (Transfer, error) { + return h.receiver().Drop(key(seat, transfer)) +} + +// DropByHand is _incoming's drop: by a person, with why, recorded as a hand act through mesh-hand-acts before +// anything goes; an act not recorded leaves the transfer open. +func (h *Holder) DropByHand(seat, transfer, by, why string) (Transfer, error) { + if strings.TrimSpace(why) == "" { + return Transfer{}, errors.New("a drop is a hand act: say why") + } + if h.Record == nil { + return Transfer{}, errors.New("no hand act can be recorded here, so nothing is dropped") + } + t, found, err := h.Transfers.Get(key(seat, transfer)) + if err != nil { + return Transfer{}, fmt.Errorf("transfer %s cannot be read, so nothing is dropped: %w", transfer, err) + } + if !found { + return Transfer{}, fmt.Errorf("transfer %s of %s is not open", transfer, seat) + } + what := fmt.Sprintf("dropped the open transfer %s of %s's incoming (%s@%s, opened by %s), by %s", transfer, seat, t.Shape.Name, t.Shape.Version, t.Opener, by) + if err := h.Record(meshhandactsclient.Request{What: what, Why: why, Cause: Seats[seat] + "_incoming"}); err != nil { + return Transfer{}, fmt.Errorf("the hand act cannot be recorded, so transfer %s stays open: %w", transfer, err) + } + return h.Drop(seat, transfer) +} + +// Incoming is what npm_incoming and go_incoming answer of a seat: its open transfers, incoming's fill (both seats +// share the store) and the cap its binding carries. +type Incoming struct { + Seat string `json:"seat"` + Transfers []Transfer `json:"transfers"` + Used uint64 `json:"used"` + Cap uint64 `json:"cap"` +} + +// IncomingOf reads a seat's open transfers and incoming's fill. +func (h *Holder) IncomingOf(seat string) (Incoming, error) { + if _, ok := Seats[seat]; !ok { + return Incoming{}, fmt.Errorf("the forge holds no seat %s", seat) + } + all, err := h.Transfers.List() + if err != nil { + return Incoming{}, fmt.Errorf("the transfers cannot be read: %w", err) + } + out := Incoming{Seat: seat, Transfers: []Transfer{}, Cap: h.CapNow()} + for _, t := range all { + if t.Shape.Seat == seat { + out.Transfers = append(out.Transfers, t) + } + } + if out.Used, err = h.Objects.Used(); err != nil { + return out, fmt.Errorf("incoming's fill cannot be read: %w", err) + } + return out, nil +} + +// Expired is told that a transfer's idle key (its key in the state transfers) ended (the bus's marker, +// parts.FollowIdle): the receiver drops a transfer with no chunk for TransferIdle with its chunks, arms one that took a +// chunk since again for the rest, and leaves one gone. It answers whether it dropped it. +func (h *Holder) Expired(id string) (bool, error) { return h.receiver().Expired(id) } + +func checkPublish(seat string, p Publish) error { + switch { + case Seats[seat] == "": + return fmt.Errorf("publish: the forge holds no seat %s", seat) + case strings.TrimSpace(p.Name) == "": + return errors.New("publish: no name") + case strings.TrimSpace(p.Version) == "": + return errors.New("publish: a package is published under the version the builder gave it") + case p.Kind != KindPackage: + return fmt.Errorf("publish: %s holds packages, not a %s", seat, p.Kind) + case !strings.HasPrefix(p.Digest, "sha256:"): + return fmt.Errorf("publish: digest %q is no sha256 digest", p.Digest) + case len(p.Parts) != 1 || p.Parts[0].Digest != p.Digest: + return errors.New("publish: a package is one file, its one part the package's digest") + } + return nil +} diff --git a/modules/gitea/internal/packageseats/holder_test.go b/modules/gitea/internal/packageseats/holder_test.go new file mode 100644 index 000000000..e8e9bdc14 --- /dev/null +++ b/modules/gitea/internal/packageseats/holder_test.go @@ -0,0 +1,306 @@ +package packageseats + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" + "git.novox.be/novox/mesh-delivery/sdk/parts" + stdio "git.novox.be/novox/mesh-sdk/go" +) + +type memObjects map[string][]byte + +func (m memObjects) Put(n string, d []byte) error { m[n] = append([]byte(nil), d...); return nil } +func (m memObjects) Get(n string) ([]byte, error) { + if d, ok := m[n]; ok { + return d, nil + } + return nil, errors.New("no object " + n) +} +func (m memObjects) Delete(n string) error { delete(m, n); return nil } +func (m memObjects) Used() (uint64, error) { + var u uint64 + for _, d := range m { + u += uint64(len(d)) + } + return u, nil +} + +type memTransfers map[string]Transfer + +func (m memTransfers) Get(id string) (Transfer, bool, error) { t, ok := m[id]; return t, ok, nil } +func (m memTransfers) Put(t Transfer) error { m[t.ID] = t; return nil } +func (m memTransfers) Delete(id string) error { delete(m, id); return nil } +func (m memTransfers) List() ([]Transfer, error) { + var out []Transfer + for _, t := range m { + out = append(out, t) + } + return out, nil +} + +type builder struct{ ok bool } + +func (b builder) Recorded(_, _, _ string) (bool, string, error) { return b.ok, "not numbered", nil } + +// forge is the forge's package registry: seat -> name@version -> file. +type forge struct { + files map[string]map[string][]byte + removed []string +} + +func (f *forge) Held(seat, name, version string) (string, bool, error) { + b, ok := f.files[seat][name+"@"+version] + if !ok { + return "", false, nil + } + return digest(b), true, nil +} +func (f *forge) Put(seat string, p Publish, file []byte) (string, error) { + if f.files[seat] == nil { + f.files[seat] = map[string][]byte{} + } + f.files[seat][p.Name+"@"+p.Version] = file + if seat == GoSeat { + return "git.example/" + p.Name, nil + } + return "", nil +} +func (f *forge) Versions(seat, name string) ([]Version, error) { + var out []Version + for k, b := range f.files[seat] { + if n, v, _ := strings.Cut(k, "@"); n == name { + out = append(out, Version{Version: v, Digest: digest(b)}) + } + } + return out, nil +} +func (f *forge) Remove(seat, name, version string) error { + delete(f.files[seat], name+"@"+version) + f.removed = append(f.removed, seat+" "+name+"@"+version) + return nil +} + +func digest(b []byte) string { s := sha256.Sum256(b); return "sha256:" + hex.EncodeToString(s[:]) } + +type said struct { + seat, event string + o Outcome +} + +type bed struct { + h *Holder + forge *forge + said []said + findings map[string]bool +} + +func newBed(numbered bool) *bed { + b := &bed{forge: &forge{files: map[string]map[string][]byte{}}, findings: map[string]bool{}} + b.h = &Holder{Callers: DefaultCallers(), Objects: memObjects{}, Transfers: memTransfers{}, Builder: builder{ok: numbered}, + Store: b.forge, Now: func() time.Time { return time.Unix(1e9, 0) }, + Say: func(seat, event string, o Outcome) error { b.said = append(b.said, said{seat, event, o}); return nil }, + Finding: func(kind, subject, _ string, raised bool) { b.findings[kind+" "+subject] = raised }} + return b +} + +func pub(name, version string, file []byte) Publish { + return Publish{Transfer: "t-" + name, Name: name, Version: version, Kind: KindPackage, Digest: digest(file), + Parts: []parts.Want{{Digest: digest(file), Size: int64(len(file))}}} +} + +const goBuild, nodeBuild = "ace.mesh-build-go", "ace.mesh-build-node" + +// **The forge module holds both package seats** (design 51 *Publishing*, *Testing* for the holder): a package's one +// file comes in parts, is checked against the builder's numbering and its digest, and is uploaded with the forge's own +// write side; the outcome is said on the seat's events. +func TestTheForgeHoldsBothPackageSeats(t *testing.T) { + file := []byte("a package's tarball") + t.Run("a publish in parts is uploaded and said published", func(t *testing.T) { + for _, seat := range []string{NpmSeat, GoSeat} { + b := newBed(true) + a, err := b.h.Publish(seat, goBuild, pub("mesh-sdk", "0.1.15", file)) + if err != nil || len(a.Lacks) != 1 || a.Transfer != "t-mesh-sdk" { + t.Fatalf("%s: publish answered %+v, %v", seat, a, err) + } + a, err = b.h.PublishPart(seat, goBuild, "t-mesh-sdk", digest(file), 0, true, file) + if err != nil || !a.Ended { + t.Fatalf("%s: the last chunk answered %+v, %v", seat, a, err) + } + if len(b.said) != 1 || b.said[0].event != EventPublished || b.said[0].o.Reference != "mesh-sdk@0.1.15" { + t.Fatalf("%s: said %+v", seat, b.said) + } + if (seat == GoSeat) != (b.said[0].o.Module != "") { + t.Errorf("%s: the Go module path said is %q", seat, b.said[0].o.Module) + } + if len(b.h.Objects.(memObjects)) != 0 || len(b.h.Transfers.(memTransfers)) != 0 { + t.Errorf("%s: the transfer's chunks or key stayed after its end", seat) + } + if v, _ := b.h.Versions(seat, "g14.anyone", "mesh-sdk"); len(v) != 1 || v[0].Digest != digest(file) { + t.Errorf("%s: versions answered %+v", seat, v) + } + // A rebuild of the published version is held: nothing uploaded, published said again. + if a, err := b.h.Publish(seat, goBuild, pub("mesh-sdk", "0.1.15", file)); err != nil || !a.Ended || len(a.Lacks) != 0 { + t.Errorf("%s: a held version answered %+v, %v", seat, a, err) + } + } + }) + // Tools review F4 and running review F2's kind: a seat's incoming is read per seat with the cap its binding + // carries; an open transfer is armed with its idle key, re-armed for the rest when it took a chunk since, and + // dropped with its chunks when it had none for 24 hours; a cap from the binding refuses a transfer past it. + t.Run("incoming: read per seat, the binding's cap, an idle transfer dropped when its key ends", func(t *testing.T) { + b := newBed(true) + now := time.Unix(1e9, 0) + b.h.Now = func() time.Time { return now } + armed := map[string]time.Duration{} + b.h.Idle = func(k string, ttl time.Duration) error { armed[k] = ttl; return nil } + b.h.Cap = func() (uint64, error) { return 1000, nil } + big := []byte(strings.Repeat("x", 600)) + if _, err := b.h.Publish(NpmSeat, nodeBuild, pub("a", "0.1.0", big)); err != nil { + t.Fatal(err) + } + if _, err := b.h.Publish(GoSeat, goBuild, pub("b", "0.1.0", big)); err != nil || armed["go-package-registry_t-b"] != TransferIdle { + t.Fatalf("publish = %v, armed %v; want the Go transfer's idle key for %s", err, armed, TransferIdle) + } + _, _ = b.h.PublishPart(NpmSeat, nodeBuild, "t-a", digest(big), 0, false, big[:500]) + in, err := b.h.IncomingOf(NpmSeat) + if err != nil || len(in.Transfers) != 1 || in.Transfers[0].Shape.Name != "a" || in.Cap != 1000 || in.Used != 500 { + t.Fatalf("IncomingOf(npm) = %+v, %v; want a's transfer alone, 500 used of the binding's 1000", in, err) + } + if _, err := b.h.Publish(NpmSeat, nodeBuild, pub("c", "0.1.0", big)); err == nil || !strings.Contains(err.Error(), "cap") { + t.Errorf("a transfer past the binding's cap = %v; want refused at the cap", err) + } + now = now.Add(time.Hour) + _, _ = b.h.PublishPart(GoSeat, goBuild, "t-b", digest(big), 0, false, big[:10]) + now = now.Add(TransferIdle - time.Hour) + if dropped, err := b.h.Expired("go-package-registry_t-b"); err != nil || dropped || armed["go-package-registry_t-b"] != time.Hour { + t.Errorf("b's key ends an hour after a chunk: %v, %v, armed %v; want kept, armed 1h", dropped, err, armed) + } + if dropped, err := b.h.Expired("npm-package-registry_t-a"); err != nil || !dropped { + t.Errorf("a's key ends 24 hours after its last chunk: %v, %v; want dropped", dropped, err) + } + if in, _ := b.h.IncomingOf(NpmSeat); len(in.Transfers) != 0 || in.Used != 10 { + t.Errorf("after a's drop: %+v; want no npm transfer and only b's 10 bytes used", in) + } + b.h.Idle = func(string, time.Duration) error { return errors.New("bus down") } + if _, err := b.h.Publish(NpmSeat, nodeBuild, pub("d", "0.1.0", file)); err == nil || !strings.Contains(err.Error(), "idle bound") { + t.Errorf("a publish whose idle key is not written = %v; want it failed loudly", err) + } + // Seams note 2: go_incoming's drop is a hand act recorded through mesh-hand-acts before anything goes; an act + // not recorded leaves the transfer open. + var acts []meshhandactsclient.Request + b.h.Record = func(meshhandactsclient.Request) error { return errors.New("mesh-hand-acts has no holder") } + if _, err := b.h.DropByHand(GoSeat, "t-b", "person.jochen", "stuck"); err == nil || !strings.Contains(err.Error(), "stays open") { + t.Errorf("a drop whose act is not recorded = %v; want refused, t-b open", err) + } + b.h.Record = func(q meshhandactsclient.Request) error { acts = append(acts, q); return nil } + if got, err := b.h.DropByHand(GoSeat, "t-b", "person.jochen", "stuck"); err != nil || got.Shape.Name != "b" || len(acts) != 1 || acts[0].Cause != "go_incoming" || !strings.Contains(acts[0].What, "t-b") || !strings.Contains(acts[0].What, "person.jochen") { + t.Errorf("a drop by hand = %+v, %v, acts %+v; want t-b dropped and its act recorded", got, err, acts) + } + if in, _ := b.h.IncomingOf(GoSeat); len(in.Transfers) != 0 { + t.Errorf("after the drop by hand: %+v; want no Go transfer", in) + } + }) + t.Run("refusals", func(t *testing.T) { + for _, c := range []struct { + name string + do func(b *bed) error + want string + }{ + {"a caller not on publish's list", func(b *bed) error { + _, err := b.h.Publish(NpmSeat, "ace.claude-code", pub("x", "0.1.0", file)) + return err + }, "not on the holder's caller list"}, + {"an unproven caller", func(b *bed) error { + _, err := b.h.Publish(NpmSeat, stdio.Unproven, pub("x", "0.1.0", file)) + return err + }, "no caller the tool runner proved"}, + {"a seat the forge does not hold", func(b *bed) error { + _, err := b.h.Publish("cargo-package-registry", goBuild, pub("x", "0.1.0", file)) + return err + }, "holds no seat"}, + {"a package in two files", func(b *bed) error { + p := pub("x", "0.1.0", file) + p.Parts = append(p.Parts, parts.Want{Digest: digest([]byte("b")), Size: 1}) + _, err := b.h.Publish(NpmSeat, goBuild, p) + return err + }, "one file"}, + {"a part from another module than its opener", func(b *bed) error { + if _, err := b.h.Publish(NpmSeat, nodeBuild, pub("x", "0.1.0", file)); err != nil { + return err + } + _, err := b.h.PublishPart(NpmSeat, goBuild, "t-x", digest(file), 0, true, file) + return err + }, "opened"}, + {"a transfer of the other seat", func(b *bed) error { + if _, err := b.h.Publish(NpmSeat, nodeBuild, pub("x", "0.1.0", file)); err != nil { + return err + } + _, err := b.h.PublishPart(GoSeat, nodeBuild, "t-x", digest(file), 0, true, file) + return err + }, "not open"}, + {"remove by a build module", func(b *bed) error { _, err := b.h.Remove(GoSeat, goBuild, "x", "0.1.1-feat.1", "tag-gone"); return err }, "not on the holder's caller list"}, + {"remove of a version with no tag", func(b *bed) error { + _, err := b.h.Remove(GoSeat, "novox.mesh-delivery-builder", "x", "0.1.1", "tag-gone") + return err + }, "has no tag"}, + } { + b := newBed(true) + if err := c.do(b); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: %v, want %q", c.name, err, c.want) + } + } + }) + t.Run("a refused publish is said refused and raises the finding; nothing is uploaded", func(t *testing.T) { + for _, c := range []struct { + name string + numbered bool + data []byte + want string + }{{"the builder did not number it", false, file, "did not number"}} { + b := newBed(c.numbered) + if _, err := b.h.Publish(NpmSeat, nodeBuild, pub("x", "0.1.0", file)); err != nil { + t.Fatal(err) + } + _, _ = b.h.PublishPart(NpmSeat, nodeBuild, "t-x", digest(file), 0, true, c.data) + if len(b.said) != 1 || b.said[0].event != EventRefused || !strings.Contains(b.said[0].o.Reason, c.want) { + t.Errorf("%s: said %+v", c.name, b.said) + } + if !b.findings["publish-refused x@0.1.0"] || len(b.forge.files[NpmSeat]) != 0 { + t.Errorf("%s: finding %v, uploaded %v", c.name, b.findings, b.forge.files) + } + } + // A part whose bytes do not match its digest is refused to its sender, to send again; nothing is said yet. + b := newBed(true) + if _, err := b.h.Publish(NpmSeat, nodeBuild, pub("x", "0.1.0", file)); err != nil { + t.Fatal(err) + } + if _, err := b.h.PublishPart(NpmSeat, nodeBuild, "t-x", digest(file), 0, true, []byte("other bytes")); err == nil || + !strings.Contains(err.Error(), "send the part again") || len(b.said) != 0 || len(b.forge.files[NpmSeat]) != 0 { + t.Errorf("a part with other bytes: %v, said %+v", err, b.said) + } + // The same version published with other content is refused, never uploaded over. + b = newBed(true) + b.forge.files[NpmSeat] = map[string][]byte{"x@0.1.0": []byte("first")} + if _, err := b.h.Publish(NpmSeat, nodeBuild, pub("x", "0.1.0", file)); err != nil || len(b.said) != 1 || b.said[0].event != EventRefused { + t.Errorf("a published version with other content: %v, said %+v", err, b.said) + } + }) + t.Run("the builder removes a tagged version; one already gone is said removed all the same", func(t *testing.T) { + b := newBed(true) + b.forge.files[GoSeat] = map[string][]byte{"x@0.1.1-feat.1": file} + for range 2 { + if _, err := b.h.Remove(GoSeat, "novox.mesh-delivery-builder", "x", "0.1.1-feat.1", "tag-gone"); err != nil { + t.Fatal(err) + } + } + if len(b.forge.removed) != 1 || len(b.said) != 2 || b.said[0].event != EventRemoved || b.said[0].o.Digest != digest(file) { + t.Errorf("removed %v, said %+v", b.forge.removed, b.said) + } + }) +} diff --git a/modules/gitea/kept.ts b/modules/gitea/kept.ts index 5f83f73a5..a591b88f0 100644 --- a/modules/gitea/kept.ts +++ b/modules/gitea/kept.ts @@ -1,11 +1,12 @@ // The states this module keeps on the bus and whether other modules may read them (novox/hq ADR 0495 clause 2; issue // 1790): the module says it itself, from its code, as it starts (index.ts calls keeps with this list), and a reader is // granted what it said (clause 3). A state is readable when another module's manifest reads it: mesh-provisioner reads -// `bound` (the forge accounts it binds). test/kept.test.ts holds the list to the manifest's `state`, and the catalogue's +// `bound` (the forge accounts it binds), the condition keeper (mesh-conditions) reads `findings`. test/kept.test.ts holds the list to the manifest's `state`, and the catalogue's // checks/readable every state another manifest reads to a readable announcement. export const KEPT: readonly { readonly name: string; readonly readable?: boolean }[] = [ { name: "announced" }, { name: "bound", readable: true }, + { name: "findings", readable: true }, ]; /** The announcement of these states, in the layout mesh-sdk's go/announce reads (novox/hq ADR 0495 clause 2): version 1, diff --git a/modules/gitea/module.json b/modules/gitea/module.json index 2fd3ae339..421ec3dd6 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -1,11 +1,35 @@ { "module": "gitea", "version": "1", + "packages": { + "builds": "0.1" + }, "moved": true, "requires": [ "postgres-database", "route", - "secret" + "secret", + { + "name": "incoming", + "storage": "object-store", + "template": "objects", + "options": { + "max-mib": 2048 + } + }, + { + "name": "transfers", + "storage": "key-value", + "template": "state" + }, + { + "name": "idle", + "storage": "key-value", + "template": "state", + "options": { + "key-ttl": true + } + } ], "contributes": { "postgres-database": { @@ -99,7 +123,7 @@ "port": 22, "protocol": "tcp", "from": "mesh", - "why": "git over ssh, gitea's own unmodified sshd. Published on the machine's own side at 222, the mesh's fixed public convention — not 22, which the machine's own daemon holds and a module does not take" + "why": "git over ssh, gitea's own unmodified sshd. Published on the machine's own side at 222, the mesh's fixed public convention \u2014 not 22, which the machine's own daemon holds and a module does not take" }, { "name": "forge-events", @@ -119,18 +143,33 @@ "git": { "scheme": "http", "port": 3000 + }, + "go-package-registry": { + "scheme": "http", + "port": 3000, + "go-path": "/api/packages/novox/go" } }, - "receives": { - "npm-package-registry": "${dir:grants}/npm.json" - }, - "grants": { - "npm-package-registry": "${dir:grants}" - }, "claims": [ { "name": "npm-package-registry", - "scope": "mesh" + "scope": "mesh", + "serves": [ + "publish", + "publish-part", + "remove", + "versions" + ] + }, + { + "name": "go-package-registry", + "scope": "mesh", + "serves": [ + "publish", + "publish-part", + "remove", + "versions" + ] }, { "name": "git", @@ -145,14 +184,7 @@ "class": "valuable", "why": "every repository, its issues and attachments, and the package registry" } - ], - "consumers": { - "npm-package-registry": { - "class": "rebuildable", - "in": "data", - "why": "a consumer's packages are published again from its source" - } - } + ] }, "config": { "url": { @@ -169,7 +201,7 @@ }, "keep-main-rules": { "type": "boolean", - "description": "Whether the module keeps main's branch protection as the mesh wants it. True: every ten minutes, and at the start, main's rule in every repository of the organisation novox is put to no push and the tools' account alone merging, and each apply of the `repositories` value narrows the main rule of the repositories it lists, requiring no status. False (the default): the module changes no existing repository's branch protection, whoever set it at the forge, but takes away a required status it finds on a rule covering main, once (ADR 0534 §1); a repository listed in `repositories` that does not exist is still made, with its first protection.", + "description": "Whether the module keeps main's branch protection as the mesh wants it. True: every ten minutes, and at the start, main's rule in every repository of the organisation novox is put to no push and the tools' account alone merging, and each apply of the `repositories` value narrows the main rule of the repositories it lists, requiring no status. False (the default): the module changes no existing repository's branch protection, whoever set it at the forge, but takes away a required status it finds on a rule covering main, once (ADR 0534 \u00a71); a repository listed in `repositories` that does not exist is still made, with its first protection.", "trusted": true, "default": false }, @@ -181,15 +213,24 @@ }, "check-gates-merge": { "type": "boolean", - "description": "Whether gitea_merge_pull_request judges mesh/check before it merges. True (the default): a head merges only on a passing mesh/check judged against the trunk's head (ADR 0447, ADR 0534 §4). False (the operator, 2026-10-25 22:56: \"run the check locally only, but not as a gate, it loses us too much time\"): a head the operator's Approve names in approved_heads merges whatever its mesh/check says (pending, failed, missing or judged on an older trunk); a head that moved, a branch no rule guards and a merge git refuses are still refused. Set together with mesh-delivery-gate's check-gates-merge.", + "description": "Whether gitea_merge_pull_request judges mesh/check before it merges. True (the default): a head merges only on a passing mesh/check judged against the trunk's head (ADR 0447, ADR 0534 \u00a74). False (the operator, 2026-10-25 22:56: \"run the check locally only, but not as a gate, it loses us too much time\"): a head the operator's Approve names in approved_heads merges whatever its mesh/check says (pending, failed, missing or judged on an older trunk); a head that moved, a branch no rule guards and a merge git refuses are still refused. Set together with mesh-delivery-gate's check-gates-merge.", "trusted": true, "default": true }, "main-mergers": { "type": "list", - "description": "The forge logins that may merge into main beside the tools' account, on every repository of the organisation (ADR 0534 §2): the operator's own, so the operator can always merge by hand at the forge. The tools' account is always on main's merge whitelist; no agent's account belongs here.", + "description": "The forge logins that may merge into main beside the tools' account, on every repository of the organisation (ADR 0534 \u00a72): the operator's own, so the operator can always merge by hand at the forge. The tools' account is always on main's merge whitelist; no agent's account belongs here.", "trusted": true, - "default": ["jschoubben"] + "default": [ + "jschoubben" + ] + }, + "callers": { + "type": "string", + "trusted": true, + "runtime": true, + "default": "", + "description": "per verb of the two package seats, the callers that may call it (JSON object of verb to callers; * every proved caller, operator the operator's session); empty is design 51's list: publish and publish-part mesh-build-go and mesh-build-node, remove mesh-delivery-builder, versions anyone" } }, "facts": { @@ -228,12 +269,6 @@ "place": ".", "unknown": "program" }, - { - "id": "grants", - "type": "directory", - "mode": "0700", - "unknown": "program" - }, { "id": "server-env", "type": "file", @@ -397,16 +432,23 @@ "provides": [ { "name": "npm-package-registry", - "credential": {"form": "pair"}, - "scope": "mesh", - "identity": { - "max": 40, - "in": "a Gitea user name" - } + "credential": { + "form": "none" + }, + "scope": "mesh" + }, + { + "name": "go-package-registry", + "credential": { + "form": "none" + }, + "scope": "mesh" }, { "name": "git", - "credential": {"form": "none"}, + "credential": { + "form": "none" + }, "scope": "mesh" }, { @@ -419,7 +461,9 @@ }, { "name": "forge-account", - "credential": {"form": "none"}, + "credential": { + "form": "none" + }, "scope": "mesh", "answered-by": "helper" } @@ -432,13 +476,11 @@ "language": "typescript", "entrypoints": [ "index.js", - "tools/index.js", - "provisioner/index.js" + "tools/index.js" ], "loads": [ "index.js", - "tools/index.js", - "provisioner/index.js" + "tools/index.js" ], "env": { "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", @@ -447,26 +489,27 @@ "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret", "MESH_GITEA_TOOLS_USER": "mesh-tools", "MESH_GITEA_STATE_DIR": "${dir:runtime-state}", - "MESH_RECEIVES": "${dir:grants}/npm.json", "MESH_GITEA_HOOK_URL": "http://172.17.0.1:3047", "MESH_GITEA_HOOK_PORT": "3047", "MESH_GITEA_HOOK_HOST": "172.17.0.1" } }, { - "name": "npm-registry", + "name": "package-registry", "kind": "bundle", "language": "go", "system": "arch", - "from": "cmd/npm-registry", - "binary": "npm-registry", + "from": "cmd/package-registry", + "binary": "package-registry", "loads": [ - "npm-registry" + "package-registry" ], "env": { "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", - "MESH_GITEA_ADMIN_USER": "mesh-admin", - "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret", + "MESH_GITEA_TOOLS_USER": "mesh-tools", + "MESH_GITEA_TOKEN_FILE": "${dir:runtime-state}/token", + "MESH_GITEA_PACKAGES_USER": "mesh-packages", + "MESH_GITEA_PACKAGES_TOKEN_FILE": "${dir:runtime-state}/packages-token", "MESH_NPM_OWNER": "novox" } }, @@ -562,7 +605,8 @@ ], "state": [ "bound", - "announced" + "announced", + "findings" ], "reads": [ "mesh-provisioner.plans", @@ -572,7 +616,14 @@ "mesh-build-node.keys", "mesh-build-image.keys", "mesh-delivery-checker.owed", - "mesh-delivery-indexer.modules", "mesh-delivery-gate.source" + ], + "invokes": [ + "mesh-delivery-indexer.indexer_read_modules", + "seat:mesh-hand-acts.hand-act", + "mesh-delivery-builder.builder_versions" + ], + "consumes": [ + "mesh-delivery-indexer.views.modules" ] } diff --git a/modules/gitea/package.json b/modules/gitea/package.json index 8c6171fe9..8e33075b9 100644 --- a/modules/gitea/package.json +++ b/modules/gitea/package.json @@ -5,7 +5,7 @@ "type": "module", "private": true, "scripts": { - "build": "tsc client.ts token.ts index.ts provisioner/index.ts provisioner/teams.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist", + "build": "tsc client.ts token.ts index.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist", "test": "npm run build && tsc --module NodeNext --moduleResolution NodeNext --target ES2022 --types node --rootDir . --outDir . $(find . -name '*.ts' ! -name '*.d.ts' ! -path './test/*' ! -path './node_modules/*' ! -path './dist/*') && node --test --experimental-strip-types 'test/*.test.ts'" }, "dependencies": { diff --git a/modules/gitea/packages-account.ts b/modules/gitea/packages-account.ts new file mode 100644 index 000000000..ffbfab7b9 --- /dev/null +++ b/modules/gitea/packages-account.ts @@ -0,0 +1,113 @@ +// The package seats write as an account of their own, never the administrator (design 51: each holder keeps its write +// side as an own secret beside a read-only tool token; the forge module's is `write:package`, its tool token +// `read:package`). +// +// The Go bundle (cmd/package-registry) serves both package seats' verbs: it reads the registry with the tools' token +// (token.ts, which carries `read:package`) and writes (a publish, a remove, retention's deletion) with this one: +// +// - **the account**: `mesh-packages`, plain, made by the module with the administrator's login as tools-account.ts +// makes `mesh-tools`; its password random, sent once and kept nowhere. +// - **the team**: `mesh-packages` in the organisation that owns the registry, writing packages and nothing else: +// no code, no issues, no pull requests, no administration. +// - **the token**: minted for the account by the administrator's login with the one scope `write:package`, kept at +// 0600 in the module's runtime state as `packages-token`, its scopes line beside it saying whose it is +// (MintedToken: one mint at a time across the bundle's processes, re-minted when the kept one is gone or other). +// +// The watcher mints it at its start (keepPackagesToken). No timer looks at it after that: the Go bundle mints nothing, +// and when the forge refuses its write it raises the finding PACKAGES_TOKEN_REFUSED in the module's findings state; the +// watcher, watching that key, looks again (a refused token is minted afresh) and clears the finding (renewOnRefusal). + +import { randomBytes } from "node:crypto"; +import { join } from "node:path"; +import { MintedToken } from "./token.js"; +import { TOOLS_ORG, type ToolsForge } from "./tools-account.js"; + +/** The account the package seats write as (internal/forgehttp PackagesAccount). */ +export const PACKAGES_ACCOUNT = "mesh-packages"; +/** Its team in the registry's organisation, and the token's name in the account's list. */ +export const PACKAGES_TEAM = "mesh-packages"; +export const PACKAGES_TOKEN_NAME = "mesh-packages"; +/** The one scope the write side needs; reads go with the tools' token. */ +export const PACKAGES_TOKEN_SCOPES: readonly string[] = ["write:package"]; +/** Where the token is kept in the module's runtime state (the manifest's MESH_GITEA_PACKAGES_TOKEN_FILE). */ +export const PACKAGES_TOKEN_FILE = "packages-token"; + +/** What the team grants: write on the organisation's packages, and no more. */ +export const PACKAGES_TEAM_UNITS = { + units_map: { "repo.packages": "write" }, + includes_all_repositories: false, + can_create_org_repo: false, +} as const; + +/** Make the packages account, plain, a member of its team. Its password is set afresh each time and kept nowhere. */ +export async function setUpPackagesAccount(forge: ToolsForge, account = PACKAGES_ACCOUNT, password: () => string): Promise { + await forge.ensureUser(account, password(), `${account}@noreply.localhost`, true); + const team = await forge.ensureTeamWith(TOOLS_ORG, PACKAGES_TEAM, { name: PACKAGES_TEAM, ...PACKAGES_TEAM_UNITS }); + await forge.addUserToTeam(team, account); +} + +/** The packages account's token, minted by the administrator's login from what the manifest hands the watcher. */ +export function packagesToken(url: string, env: NodeJS.ProcessEnv = process.env, log?: (line: string) => void): MintedToken { + const admin = env.MESH_GITEA_ADMIN_USER; + const passwordFile = env.MESH_GITEA_ADMIN_PASSWORD_FILE; + const stateDir = env.MESH_GITEA_STATE_DIR; + const missing = [ + admin ? null : "MESH_GITEA_ADMIN_USER", + passwordFile ? null : "MESH_GITEA_ADMIN_PASSWORD_FILE", + stateDir ? null : "MESH_GITEA_STATE_DIR", + ].filter((v): v is string => v !== null); + if (missing.length) throw new Error(`the package seats' write token cannot be minted: ${missing.join(", ")} not set by the manifest`); + const account = env.MESH_GITEA_PACKAGES_USER || PACKAGES_ACCOUNT; + if (account === admin) throw new Error(`MESH_GITEA_PACKAGES_USER names the admin account "${admin}"; the package seats write as an account of their own`); + return new MintedToken({ + url, admin: admin!, passwordFile: passwordFile!, file: join(stateDir!, PACKAGES_TOKEN_FILE), account, + name: PACKAGES_TOKEN_NAME, scopes: PACKAGES_TOKEN_SCOPES, log, + setUp: async (password) => { + const { GiteaAdmin } = await import("./client.js"); + await setUpPackagesAccount(new GiteaAdmin(url, admin!, password), account, () => randomBytes(24).toString("base64url")); + }, + }); +} + +/** + * One look at the packages token: mint it when none is kept, then ask the forge one read the scope covers (the + * organisation's package list); a 401 renews it. Answers what was done, for the watcher's line. + */ +export async function keepPackagesToken(source: MintedToken, url: string, owner = TOOLS_ORG, fetchImpl: typeof fetch = fetch): Promise { + const token = await source.current(); + const look = (t: string) => fetchImpl(`${url.replace(/\/+$/, "")}/api/v1/packages/${encodeURIComponent(owner)}?limit=1`, + { headers: { Authorization: `token ${t}`, Accept: "application/json" } }); + const res = await look(token); + if (res.status === 401 || MintedToken.lacksScope(res.status, await res.text())) { + const fresh = await source.renew(token); + const again = await look(fresh); + if (!again.ok) throw new Error(`the forge answered ${again.status} to the renewed package token's read`); + return "the forge rejected the package seats' write token; minted afresh"; + } + if (!res.ok) throw new Error(`the forge answered ${res.status} to the package token's read`); + return "the package seats' write token holds"; +} + +/** The finding the Go bundle raises in the module's findings state when the forge refuses the write token (cmd/package-registry PackagesTokenRefusedKey). */ +export const PACKAGES_TOKEN_REFUSED = "seat.gitea.packages-token-refused_write"; + +/** One change of the findings state, as its watch hands it. */ +export interface FindingChange { readonly key: string; readonly op: "put" | "delete" } + +/** + * The watcher's answer to the Go bundle's refusal: on the finding's put, look at the token (a refused one is minted + * afresh) and clear the finding; a look that fails leaves the finding standing, said. Other keys are not its own. + */ +export function renewOnRefusal(source: MintedToken, url: string, findings: { delete(key: string): Promise }, + log: (line: string) => void, fetchImpl: typeof fetch = fetch): (change: FindingChange) => Promise { + return async (change) => { + if (change.op !== "put" || change.key !== PACKAGES_TOKEN_REFUSED) return; + try { + log(await keepPackagesToken(source, url, TOOLS_ORG, fetchImpl)); + } catch (err) { + log(`the package seats' write token was refused and could not be minted afresh; the finding stands — ${err instanceof Error ? err.message : err}`); + return; + } + await findings.delete(change.key); + }; +} diff --git a/modules/gitea/provisioner/index.ts b/modules/gitea/provisioner/index.ts deleted file mode 100644 index 9efc083b7..000000000 --- a/modules/gitea/provisioner/index.ts +++ /dev/null @@ -1,54 +0,0 @@ -// gitea's provisioner — the adapter that makes gitea a provider of the mesh -// `npm-package-registry` interface. The reconcile loop, the contributions file, and reading the -// mesh's minted password are the sdk harness's; this writes only the per-service half: how gitea -// creates and removes a consumer's npm credential (novox/hq ADR 0048/0076). -// -// **A package registry seat is one per ecosystem (novox/hq ADR 0109).** gitea holds the npm seat -// (ADR 0110). Adding cargo or PyPI is adding a provision — another `provides` entry, another -// `receives` path and another registration below — not widening this one. `git`, which gitea also -// provides, mints nothing and so registers nothing here: the mesh's own repositories are public, -// and a clone credential is not yet decided (ADR 0111). -// -// The `npm-package-registry` interface: a consumer authenticates to the npm registry at -// `/api/packages/novox/npm/` with basic auth, as `as` with the password the mesh minted, and can -// read packages under the `@novox` scope, and write them unless it asked for read only (teams.ts). The -// registry's npm owner is the gitea org `novox`; a consumer is a gitea *user* on one of that org's teams. -// -// **The user name and password are the mesh's, not the provisioner's (ADR 0048).** The mesh derives -// the login and hands it to both ends, and mints the password. gitea creates a user under exactly -// that login and sets exactly that password every run — so a rotation takes — and seals nothing: the -// consumer already has its copy through the mesh's own channel. -// -// The admin calls run through GiteaAdmin (basic auth as the mesh's gitea admin), which is the -// module's one boundary to the forge's admin API (see client.ts). - -import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; -import { GiteaAdmin } from "../client.js"; -import { grant, holds } from "./teams.js"; - -// Which team a consumer joins — `packages` to publish, `readers` to read only — is teams.ts's. - -const gitea = GiteaAdmin.fromEnv(); - -// Where this registration's contributions land comes from $MESH_RECEIVES, never a path written -// here: the mesh writes the file where the manifest's `receives` says, and a second copy of that -// path in code would drift from it. One variable carries one path, so a second registration in this -// module needs the mesh to say where each provision's file is — not yet possible, and not faked. -runProvisioner("npm-package-registry", { - async create(p: Provision): Promise { - // The org and its teams are ensured per create: idempotent, and no separate bootstrap step. The - // user carries the consumer's login and the mesh's minted password, set every run so a rotation - // takes; membership of its team is what grants it what it may do. - await grant(gitea, p); - }, - - async remove(p: { as: string }): Promise { - // Login prohibited, never deleted (novox/hq issue 241: a withdrawal never destroys a consumer's data — on 2026-10-04 a misread grants file withdrew every consumer at once): deleting purges every repository the user owns. - await gitea.prohibitLogin(p.as); - }, - // Asked every minute by the harness: whether the backend still holds this consumer exactly as - // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). - async holds(p: Provision): Promise { - return holds(gitea, p); - }, -}); diff --git a/modules/gitea/provisioner/teams.ts b/modules/gitea/provisioner/teams.ts deleted file mode 100644 index 61100be8b..000000000 --- a/modules/gitea/provisioner/teams.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Which team of the forge's npm owner a consumer of `npm-package-registry` joins, and the per-consumer -// half of the provisioner around it — pure of the forge's address, so it is tested with a stand-in. -// -// **Two teams, by what the consumer said it needs** (novox/hq issue 624). A consumer that publishes — -// the build modules, whose builds publish the SDK and every TypeScript module's packages — joins `packages`: -// code read on the org's repositories and write on its packages. A consumer that contributes -// `{"access": "read"}` joins `readers`: code read and package read, and no write. The lab is the first: it -// clones branches that are not yet reviewed and runs their code beside its credential, and code that is -// not reviewed must not be able to publish the mesh's packages with it. - -/** The npm registry's owner: the gitea org every consumer's packages live under (ADR 0076). */ -export const ORG = "novox"; -/** The team whose members read and write the org's packages. */ -export const PACKAGE_TEAM = "packages"; -/** The team whose members read the org's code and packages, and write nothing. */ -export const READ_TEAM = "readers"; - -/** The team a consumer joins, from what it contributed; an access it may not ask for is refused. */ -export function teamFor(values: Readonly> | undefined): { team: string; packageWrite: boolean } { - const access = values?.access; - if (access === undefined || access === "write") return { team: PACKAGE_TEAM, packageWrite: true }; - if (access === "read") return { team: READ_TEAM, packageWrite: false }; - throw new Error(`npm-package-registry: access ${JSON.stringify(access)} is not "read" or "write"`); -} - -/** What the adapter asks of the forge's admin API (client.ts GiteaAdmin). */ -export interface Forge { - ensureOrg(org: string): Promise; - ensureTeam(org: string, team: string, packageWrite: boolean): Promise; - ensureUser(username: string, password: string, email: string): Promise; - addUserToTeam(teamId: number, username: string): Promise; - removeUserFromTeam(teamId: number, username: string): Promise; - holdsTeamMember(org: string, team: string, username: string, password: string): Promise; -} - -export interface Consumer { - readonly as: string; - readonly password: string; - readonly values: Readonly>; -} - -/** - * grant makes the consumer's user, with exactly the mesh's password, a member of its one team. A consumer - * that asks for read only is also taken out of the package team, so one that asked for write before and - * read now loses the write at once. - */ -export async function grant(forge: Forge, p: Consumer): Promise { - const { team, packageWrite } = teamFor(p.values); - await forge.ensureOrg(ORG); - const teamId = await forge.ensureTeam(ORG, team, packageWrite); - // The address is gitea's own convention for one that is not real: its email validation requires a - // dotted domain, so `@localhost` was refused at create, while `@noreply.localhost` is the shape gitea - // itself gives hidden addresses. - await forge.ensureUser(p.as, p.password, `${p.as}@noreply.localhost`); - await forge.addUserToTeam(teamId, p.as); - if (!packageWrite) { - await forge.removeUserFromTeam(await forge.ensureTeam(ORG, PACKAGE_TEAM, true), p.as); - } -} - -/** holds is whether the forge still holds the consumer as the mesh gave it: its login, in its team. */ -export async function holds(forge: Forge, p: Consumer): Promise { - return forge.holdsTeamMember(ORG, teamFor(p.values).team, p.as, p.password); -} diff --git a/modules/gitea/test/bundle-import.test.ts b/modules/gitea/test/bundle-import.test.ts index 5fb550b99..14f7786cb 100644 --- a/modules/gitea/test/bundle-import.test.ts +++ b/modules/gitea/test/bundle-import.test.ts @@ -23,7 +23,7 @@ test("importing the bundle resolves while the broker keeps refusing the binds", await import(${JSON.stringify(index)}); console.log("imported"); process.exit(0);`; - const env = { ...process.env, MESH_GITEA_TOKEN: "x", MESH_GITEA_URL: "http://127.0.0.1:1" }; + const env = { ...process.env, GITEA_TOKEN: "x", MESH_GITEA_URL: "http://127.0.0.1:1" }; for (const k of ["MESH_GITEA_STATE_DIR", "MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD", "MESH_SERVED_MODULE"]) delete env[k]; const child = spawn(process.execPath, ["--input-type=module", "-e", script], { cwd: join(here, ".."), env, stdio: ["ignore", "pipe", "pipe"] }); let out = ""; @@ -86,7 +86,7 @@ test("a bundle whose read of the checker's owed is granted late writes the lates await new Promise((r) => setTimeout(r, 200)); console.log(JSON.stringify({ tools, bound, watched, statuses })); process.exit(0);`; - const env = { ...process.env, MESH_GITEA_TOKEN: "x" }; + const env = { ...process.env, GITEA_TOKEN: "x" }; for (const k of ["MESH_GITEA_STATE_DIR", "MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD", "MESH_SERVED_MODULE", "MESH_GITEA_URL"]) delete env[k]; const child = spawn(process.execPath, ["--input-type=module", "-e", script], { cwd: join(here, ".."), env, stdio: ["ignore", "pipe", "pipe"] }); let out = ""; @@ -141,7 +141,7 @@ test("importing the bundle resolves, its tools registered, while the forge never const { collectTools } = await import("@novox/mesh-sdk/tools"); console.log(JSON.stringify({ imported: true, tools: collectTools().flatMap((r) => r.tools.map((t) => t.name)) })); process.exit(0);`; - const env = { ...process.env, MESH_GITEA_TOKEN: "x", MESH_GITEA_ADMIN_USER: "admin", MESH_GITEA_HOOK_URL: "http://127.0.0.1:1" }; + const env = { ...process.env, GITEA_TOKEN: "x", MESH_GITEA_ADMIN_USER: "admin", MESH_GITEA_HOOK_URL: "http://127.0.0.1:1" }; for (const k of ["MESH_SERVED_MODULE", "MESH_GITEA_ADMIN_PASSWORD", "MESH_GITEA_CONFIG_FILE"]) delete env[k]; const child = spawn(process.execPath, ["--input-type=module", "-e", script], { cwd: join(here, ".."), env, stdio: ["ignore", "pipe", "pipe"] }); let out = ""; diff --git a/modules/gitea/test/kept.test.ts b/modules/gitea/test/kept.test.ts index f0ca9ec98..a526eb104 100644 --- a/modules/gitea/test/kept.test.ts +++ b/modules/gitea/test/kept.test.ts @@ -10,13 +10,14 @@ test("the states the module announces are the states its manifest declares", asy const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8")); const declared = (manifest.state as (string | { name: string })[]).map((s) => (typeof s === "string" ? s : s.name)).sort(); assert.deepEqual(KEPT.map((k) => k.name).sort(), declared); - assert.deepEqual(KEPT.filter((k) => k.readable).map((k) => k.name), ["bound"], "bound alone is readable: mesh-provisioner reads it"); + assert.deepEqual(KEPT.filter((k) => k.readable).map((k) => k.name), ["bound", "findings"], + "bound and findings alone are readable: mesh-provisioner reads bound, the condition keeper findings"); }); test("the announcement has the layout mesh-sdk's go/announce reads: version 1, the module, the node, the states sorted by name", async () => { const { statesAnnouncement } = await import("../kept.ts"); - assert.deepEqual(statesAnnouncement("gitea", "ace"), { v: 1, module: "gitea", node: "ace", states: [{ name: "announced" }, { name: "bound", readable: true }] }); - assert.deepEqual(statesAnnouncement("gitea", undefined), { v: 1, module: "gitea", states: [{ name: "announced" }, { name: "bound", readable: true }] }); + assert.deepEqual(statesAnnouncement("gitea", "ace"), { v: 1, module: "gitea", node: "ace", states: [{ name: "announced" }, { name: "bound", readable: true }, { name: "findings", readable: true }] }); + assert.deepEqual(statesAnnouncement("gitea", undefined), { v: 1, module: "gitea", states: [{ name: "announced" }, { name: "bound", readable: true }, { name: "findings", readable: true }] }); }); test("index.ts says the announcement as the event `states` before the tools are served", () => { diff --git a/modules/gitea/test/teams.test.ts b/modules/gitea/test/teams.test.ts deleted file mode 100644 index 11def05ea..000000000 --- a/modules/gitea/test/teams.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; - -import { grant, holds, PACKAGE_TEAM, READ_TEAM, teamFor, type Forge } from "../provisioner/teams.ts"; - -// Defends novox/hq issue 624: the lab reads the forge with a credential of its own that can write no -// package. A consumer that contributes {"access": "read"} joins a team with code and package read only. - -function aForge(): { forge: Forge; did: string[] } { - const did: string[] = []; - const teams: Record = {}; - const forge: Forge = { - async ensureOrg(org) { did.push(`org ${org}`); }, - async ensureTeam(org, team, packageWrite) { - did.push(`team ${org}/${team} packages:${packageWrite ? "write" : "read"}`); - return (teams[team] ??= Object.keys(teams).length + 1); - }, - async ensureUser(username) { did.push(`user ${username}`); }, - async addUserToTeam(id, username) { did.push(`add ${username} to ${id}`); }, - async removeUserFromTeam(id, username) { did.push(`remove ${username} from ${id}`); }, - async holdsTeamMember(org, team, username) { did.push(`holds ${username} in ${org}/${team}`); return true; }, - }; - return { forge, did }; -} - -test("a consumer that asks nothing, or write, publishes: the package team", () => { - assert.deepEqual(teamFor({}), { team: PACKAGE_TEAM, packageWrite: true }); - assert.deepEqual(teamFor({ access: "write" }), { team: PACKAGE_TEAM, packageWrite: true }); -}); - -test("a consumer that asks to read joins the readers, who write no package", () => { - assert.deepEqual(teamFor({ access: "read" }), { team: READ_TEAM, packageWrite: false }); - assert.throws(() => teamFor({ access: "admin" }), /not "read" or "write"/); -}); - -test("a reader is made, put on the readers team, and taken off the package team", async () => { - const { forge, did } = aForge(); - await grant(forge, { as: "lab-shanks", password: "pw", values: { access: "read" } }); - assert.deepEqual(did, [ - "org novox", - "team novox/readers packages:read", - "user lab-shanks", - "add lab-shanks to 1", - "team novox/packages packages:write", - "remove lab-shanks from 2", - ]); - did.length = 0; - await holds(forge, { as: "lab-shanks", password: "pw", values: { access: "read" } }); - assert.deepEqual(did, ["holds lab-shanks in novox/readers"]); -}); - -test("a publisher is made and put on the package team, as before", async () => { - const { forge, did } = aForge(); - await grant(forge, { as: "build-agent-ace", password: "pw", values: {} }); - assert.deepEqual(did, ["org novox", "team novox/packages packages:write", "user build-agent-ace", "add build-agent-ace to 1"]); -}); diff --git a/modules/gitea/test/token.test.ts b/modules/gitea/test/token.test.ts index c6399fcef..54fc995fc 100644 --- a/modules/gitea/test/token.test.ts +++ b/modules/gitea/test/token.test.ts @@ -293,11 +293,11 @@ test("first start: mints with the admin account, keeps the token at 0600, asks f assert.equal(repos[0]?.full_name, "novox/hq"); assert.equal(forge.mints, 1); // Never write:admin (novox/hq ADR 0266): the forge's administration is the operator's, not a tool's. - assert.deepEqual(forge.lastScopes, ["write:repository", "write:issue", "read:user"]); + assert.deepEqual(forge.lastScopes, ["write:repository", "write:issue", "read:user", "read:package"]); assert.deepEqual(forge.lastScopes, [...TOKEN_SCOPES]); const token = forge.tokens.get("mesh-tools")!; assert.equal(await readFile(file, "utf8"), token + "\n"); - assert.equal(await readFile(file + ".scopes", "utf8"), "read:user,write:issue,write:repository\n"); + assert.equal(await readFile(file + ".scopes", "utf8"), "read:package,read:user,write:issue,write:repository\n"); assert.equal((await stat(file)).mode & 0o777, 0o600); // Said that it minted, and where it keeps it — never what it is. assert.ok(logs.some((l) => l.startsWith("minted a token")), logs.join("\n")); @@ -500,16 +500,18 @@ test("a configured token wins, and is reported rather than minted over when the const { env } = await delivered(forge); const before = forge.mints; - const client = GiteaClient.fromEnv({ ...env, MESH_GITEA_TOKEN: "one-somebody-pasted-in" }); + const client = GiteaClient.fromEnv({ ...env, GITEA_TOKEN: "one-somebody-pasted-in" }); await assert.rejects(client.listRepos(), /rejected the configured Gitea token \(401\)/); assert.equal(forge.mints, before); + // MESH_GITEA_TOKEN is no word of the mesh's (nothing sets it, checks/environment): it is not read, the module mints. + await assert.doesNotReject(GiteaClient.fromEnv({ ...env, MESH_GITEA_TOKEN: "one-somebody-pasted-in" }).listRepos()); }); test("nothing to mint with and no token: the client says what is missing", async () => { assert.throws( () => GiteaClient.fromEnv({ MESH_GITEA_URL: forge.url, MESH_GITEA_ADMIN_USER: ADMIN }), - /set MESH_GITEA_TOKEN, or MESH_GITEA_ADMIN_PASSWORD_FILE, MESH_GITEA_STATE_DIR/, + /set GITEA_TOKEN, or MESH_GITEA_ADMIN_PASSWORD_FILE, MESH_GITEA_STATE_DIR/, ); }); diff --git a/modules/gitea/test/tools-account.test.ts b/modules/gitea/test/tools-account.test.ts index 6bf55535c..d05ec24b6 100644 --- a/modules/gitea/test/tools-account.test.ts +++ b/modules/gitea/test/tools-account.test.ts @@ -14,9 +14,10 @@ import { mkdtemp, readFile, writeFile, mkdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { MintedToken } from "../dist/token.js"; +import { MintedToken, TOKEN_SCOPES } from "../dist/token.js"; import { GiteaClient } from "../dist/client.js"; import { TOOLS_ACCOUNT, TOOLS_TEAM_UNITS, BUILDER_ACCOUNT, AGENT_ACCOUNT, meshAccounts } from "../dist/tools-account.js"; +import { PACKAGES_ACCOUNT, PACKAGES_TEAM_UNITS, PACKAGES_TOKEN_REFUSED, keepPackagesToken, packagesToken, renewOnRefusal } from "../dist/packages-account.js"; const ADMIN = "mesh-admin"; const PASSWORD = "the-vault-minted-this"; @@ -116,6 +117,13 @@ function fakeForge() { } } if (req.method === "GET" && p === "/user") return json(res, 200, { login: me, is_admin: isAdmin(me) }); + // The organisation's packages: a token whose scope reaches packages, of a member of a team writing them. + if (req.method === "GET" && p === "/packages/novox") { + const scopes = tokens.find((t) => `token ${t.sha1}` === req.headers.authorization)?.scopes ?? []; + if (!scopes.some((s) => /:package$/.test(s))) return json(res, 403, { message: "token does not have at least one of required scope(s): [read:package]" }); + if (!teams.some((t) => t.members.has(me) && t.units.units_map?.["repo.packages"] === "write")) return json(res, 403, { message: "no access to the packages" }); + return json(res, 200, []); + } m = p.match(/^\/users\/([^/]+)$/); if (m && req.method === "GET") { const u = users.get(decodeURIComponent(m[1])); @@ -359,6 +367,44 @@ test("the manifest hands the tools their account, and the Go forge bundle holds const forgeBundle = manifest.build.artifacts.find((a: any) => a.name === "forge"); assert.ok(forgeBundle.env.MESH_GITEA_TOKEN_FILE); assert.equal(forgeBundle.env.MESH_GITEA_ADMIN_PASSWORD_FILE, undefined); + // The package registry's bundle reads with the tools' token and writes with the packages account's (design 51). + const packages = manifest.build.artifacts.find((a: any) => a.name === "package-registry"); + assert.equal(packages.env.MESH_GITEA_TOKEN_FILE, forgeBundle.env.MESH_GITEA_TOKEN_FILE); + assert.equal(packages.env.MESH_GITEA_PACKAGES_USER, PACKAGES_ACCOUNT); + assert.match(packages.env.MESH_GITEA_PACKAGES_TOKEN_FILE, /^\$\{dir:runtime-state\}\/packages-token$/); + assert.equal(packages.env.MESH_GITEA_ADMIN_PASSWORD_FILE, undefined); + assert.equal(code.env.MESH_GITEA_STATE_DIR, "${dir:runtime-state}", "the watcher mints the packages token where the bundle reads it"); +}); + +test("the package seats write as an account of their own: plain, in a team writing only packages, its token write:package alone, renewed when the forge rejects it", async () => { + const env = await environment(); + const source = packagesToken(forge.url, env, () => {}); + assert.equal(await keepPackagesToken(source, forge.url), "the package seats' write token holds"); + const account = forge.users.get(PACKAGES_ACCOUNT); + assert.ok(account, "the module made the packages account"); + assert.equal(account!.admin, false, "the packages account is no administrator"); + const held = forge.tokens.filter((t) => t.name === "mesh-packages"); + assert.deepEqual(held.map((t) => [t.user, t.scopes]), [[PACKAGES_ACCOUNT, ["write:package"]]]); + assert.equal(await readFile(join(env.MESH_GITEA_STATE_DIR, "packages-token"), "utf8"), held[0].sha1 + "\n"); + assert.equal(await readFile(join(env.MESH_GITEA_STATE_DIR, "packages-token.scopes"), "utf8"), "write:package for mesh-packages\n"); + const team = forge.teams.find((t) => t.name === "mesh-packages"); + assert.ok(team?.members.has(PACKAGES_ACCOUNT)); + assert.deepEqual(team!.units.units_map, PACKAGES_TEAM_UNITS.units_map, "the team writes packages and nothing else"); + // The forge drops the token behind the module's back: the Go bundle's refusal (the finding's put) mints it afresh and + // clears the finding; no timer looks at it, and another key of the findings state is not its own. + forge.tokens.splice(forge.tokens.indexOf(held[0]), 1); + const cleared: string[] = []; + const said: string[] = []; + const renew = renewOnRefusal(source, forge.url, { delete: async (k) => { cleared.push(k); } }, (l) => said.push(l)); + await renew({ key: "seat.gitea.other", op: "put" }); + await renew({ key: PACKAGES_TOKEN_REFUSED, op: "delete" }); + assert.equal(forge.tokens.filter((t) => t.name === "mesh-packages").length, 0, "nothing renews but the refusal"); + await renew({ key: PACKAGES_TOKEN_REFUSED, op: "put" }); + assert.deepEqual(said, ["the forge rejected the package seats' write token; minted afresh"]); + assert.deepEqual(cleared, [PACKAGES_TOKEN_REFUSED]); + assert.equal(forge.tokens.filter((t) => t.name === "mesh-packages").length, 1); + // The tools' token reads packages too: read:package (design 51's read-only tool token). + assert.ok(TOKEN_SCOPES.includes("read:package")); }); // Review of issue 469: what reads the rules' list is moved to the branch, and nothing leaves the tools without a token. diff --git a/modules/gitea/test/views.test.ts b/modules/gitea/test/views.test.ts new file mode 100644 index 000000000..3f53160c9 --- /dev/null +++ b/modules/gitea/test/views.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { ViewFollower, findingKey, unansweredFinding, viewTools, type ViewAnswer, type ViewChange, type ViewEvent } from "../views.ts"; + +// ADR 0615: the module asks the indexer's read tool once, then keeps its picture of `modules` from the indexer's views +// events; a gap asks once more; an owner that is down is asked again and never read instead. + +const made = "2026-11-04T12:00:00Z"; +const answer = (revision: number, values: Record): ViewAnswer => + ({ v: 1, owner: "o", state: "modules", revision, created: made, values }); +const ev = (revision: number, key: string, value?: unknown, extra: Partial = {}): ViewEvent => + ({ v: 1, owner: "o", state: "modules", key, revision, created: made, value, deleted: value === undefined, ...extra }); + +test("a follower takes the first view, then the events, and asks again only on a gap", async () => { + const changes: ViewChange[] = []; + const said: string[] = []; + let asks = 0; + let down = 2; + const raised: string[][] = []; + const f = new ViewFollower({ owner: "o", state: "modules", tool: "o.read", + ask: async () => { if (down-- > 0) throw new Error("no responders"); asks++; return answer(9, { a: { repository: "novox/a" }, b: { repository: "novox/b" } }); }, + take: (c) => { changes.push(c); }, raise: (_a, e) => { raised.push(["raised", e]); }, clear: () => { raised.push(["cleared", ""]); }, say: (l) => said.push(l), sleep: async () => {}, now: (() => { let t = 0; return () => (t += 200_000); })() }); + await f.start(); + assert.equal(f.ready, true); + assert.equal(f.values.size, 2); + assert.deepEqual(raised.map((r) => r.slice(0, 2)), [["raised", "no responders"], ["cleared", ""]]); + assert.ok(said.some((l) => l.includes("no first view of o's modules") && l.includes("o.read")), said.join("|")); + await f.hear(ev(8, "a", { repository: "old" })); // already in the picture + await f.hear(ev(10, "c", { repository: "novox/c" })); + await f.hear(ev(11, "a")); // deleted + assert.deepEqual([f.revision, f.values.has("a"), f.values.has("c"), asks], [11, false, true, 1]); + assert.deepEqual(changes.slice(-2).map((c) => [c.key, c.op]), [["c", "put"], ["a", "delete"]]); + await f.hear(ev(13, "d", { repository: "x" })); // revision 12 missed: a gap + assert.equal(asks, 2); + await f.hear(ev(1, "e", { repository: "x" }, { created: "2026-11-04T13:00:00Z" })); // the state made again: a gap + assert.equal(asks, 3); + await f.hear(ev(10, "f", undefined, { gap: true })); // the next revision, too large to carry: a gap + assert.equal(asks, 4); + await f.hear({ ...ev(10, "g", { repository: "x" }), state: "other" }); // another state + assert.equal(f.values.has("g"), false); +}); + +test("the reader's two tools say where the first view stands and ask it again by hand", async () => { + let asks = 0; + const acts: Record[] = []; + const follower = new ViewFollower({ owner: "o", state: "modules", tool: "o.read", ask: async () => { asks++; return answer(5, { a: 1 }); }, + take: () => {}, sleep: async () => {} }); + const [first, again] = viewTools(() => ({ follower, owner: "o", tool: "o.read", state: "modules" }), async (act) => { acts.push(act); }); + assert.deepEqual(await first.run({}), { views: [{ owner: "o", tool: "o.read", state: "modules", revision: 0, created: "", keys: 0, answered: false, asks: 0, "by-hand": 0 }] }); + const after = (await again.run({})) as { views: { revision: number; "by-hand": number; asks: number }[] }; + assert.deepEqual([after.views[0].revision, after.views[0]["by-hand"], asks, acts.length], [5, 1, 1, 1]); + await assert.rejects(viewTools(() => ({ follower, owner: "o", tool: "o.read", state: "modules" }), async () => { throw new Error("no holder"); })[1].run({}), /not recorded/); + assert.deepEqual(await viewTools(() => undefined, async () => {})[0].run({}), { views: [] }); +}); + +test("the finding of a first view unanswered is the one mesh-sdk writes: key, kind, subject and the words", () => { + const f = unansweredFinding("gitea", "mesh-delivery-indexer", "modules", "mesh-delivery-indexer.indexer_read_modules", new Date("2026-11-04T12:00:00Z"), "no responders", new Date("2026-11-04T12:03:00Z")); + assert.equal(f.key, "module.gitea.mesh-delivery-indexer.modules.first-view-unanswered"); + assert.equal(findingKey("module", "a.b", "k"), "module.a.b.k"); + assert.equal(f.value.severity, "warning"); + assert.match(String(f.value.summary), /gitea has no first view of mesh-delivery-indexer's modules: .* \(no responders\)/); +}); diff --git a/modules/gitea/token.ts b/modules/gitea/token.ts index 48592f33a..2c02a65d7 100644 --- a/modules/gitea/token.ts +++ b/modules/gitea/token.ts @@ -47,6 +47,9 @@ export const TOKEN_NAME = "mesh-tools"; * The least the fifteen tools and the watcher need (gitea's route groups, 1.20+ scoped tokens): * write:repository — create/delete repositories, pull requests (list/get/open/merge); * write:issue — issues, comments, labels; + * read:package — the package registry's reads by the Go bundle (cmd/package-registry): the package seats' + * held, versions and the retention's survey (design 51: a read-only tool token; the + * writes go with the packages account's own token, packages-account.ts); * read:user — GET /user/repos, which the watcher's poll and gitea_list_repos both call. * It sits under the `user` category despite listing repositories, not `repository` * — confirmed against the running forge (1.27.3), which answered @@ -62,7 +65,7 @@ export const TOKEN_NAME = "mesh-tools"; * `403 token does not have at least one of required scope(s)`, and the client treats that like a * 401 — the source re-mints by name, with the whole list, and the call is retried once. */ -export const TOKEN_SCOPES: readonly string[] = ["write:repository", "write:issue", "read:user"]; +export const TOKEN_SCOPES: readonly string[] = ["write:repository", "write:issue", "read:user", "read:package"]; /** Where a client's token comes from, and what to do when the forge says it is wrong. */ export interface TokenSource { @@ -82,7 +85,7 @@ export class ConfiguredToken implements TokenSource { async renew(): Promise { throw new Error( - "the forge rejected the configured Gitea token (401). It was set explicitly (settings or MESH_GITEA_TOKEN), " + + "the forge rejected the configured Gitea token (401). It was set explicitly (settings or GITEA_TOKEN), " + "so the module does not mint over it — fix it, or unset it and the module mints its own", ); } @@ -173,7 +176,7 @@ export class MintedToken implements TokenSource { stateDir ? null : "MESH_GITEA_STATE_DIR", ].filter((v): v is string => v !== null); if (missing.length) { - throw new Error(`no Gitea token, and nothing to mint one with — set MESH_GITEA_TOKEN, or ${missing.join(", ")}`); + throw new Error(`no Gitea token, and nothing to mint one with — set GITEA_TOKEN, or ${missing.join(", ")}`); } // The tools' own account, never the administrator (novox/hq ADR 0365 §2): MESH_GITEA_TOOLS_USER names it. const account = env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT; diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index 9b183a550..94736c81c 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -24,8 +24,9 @@ import { /** A head named for a merge: at least 12 hex characters of its sha (novox/hq issue 381), up to a sha-256 commit's 64. */ const REVIEWED_HEAD = /^[0-9a-f]{12,64}$/; -/** The forge's container, where its repositories and git are: the note is written there (novox/hq ADR 0239). */ -const forgeContainer = process.env.MESH_GITEA_CONTAINER || "gitea"; +/** The forge's container, where its repositories and git are: the note is written there (novox/hq ADR 0239). Its name + * is the manifest's container's; no variable names it (a MESH_* word nothing sets is refused, checks/environment). */ +const forgeContainer = "gitea"; /** A protection rule as a person reads it: what it guards, not every field the forge keeps. */ export function summarised(p: BranchProtection) { diff --git a/modules/gitea/tsconfig.json b/modules/gitea/tsconfig.json index 7b5440b7a..5e8518763 100644 --- a/modules/gitea/tsconfig.json +++ b/modules/gitea/tsconfig.json @@ -8,5 +8,5 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["client.ts", "token.ts", "pulls.ts", "protection.ts", "index.ts", "provisioner/index.ts", "provisioner/teams.ts", "tools/index.ts"] + "include": ["client.ts", "token.ts", "pulls.ts", "protection.ts", "index.ts", "tools/index.ts"] } diff --git a/modules/gitea/views.ts b/modules/gitea/views.ts new file mode 100644 index 000000000..dbcc7d6a4 --- /dev/null +++ b/modules/gitea/views.ts @@ -0,0 +1,241 @@ +// A reader's picture of one state of the indexer or the assigner (novox/hq ADR 0615 §1, §4 to §6; design 25 § 12.2): asked once +// of the owner's read tool, then kept by the owner's views events alone. It is the TypeScript form of the Go reader +// (mesh-delivery sdk/views Follower) and follows the same rules: +// +// - at its start it asks the tool until it is answered, waiting longer between asks (up to a minute), and past two minutes +// says so naming the owner and the tool; it never reads the owner's state instead; +// - an event of another `created` is a gap (the owner's state was made again); one at or below the revision held is already +// in the picture; one past the next revision is a gap; one with `gap: true` (a value too large to carry) is a gap; +// - a gap asks the tool once more, said in the log; +// - events heard while an ask runs are taken again after its answer, so none is lost. + +/** The answer of the owner's read tool with `values`: one page of the state, at the owner's revision. */ +export interface ViewAnswer { + v: number; + owner: string; + state: string; + revision: number; + created: string; + values: Record; + next?: string; +} + +/** One change of one key, said on the owner's views family. */ +export interface ViewEvent { + v: number; + owner: string; + state: string; + key: string; + revision: number; + created: string; + deleted?: boolean; + value?: unknown; + gap?: boolean; +} + +/** One change of the picture, in the form the module's watch handed before. */ +export interface ViewChange { + key: string; + op: "put" | "delete"; + value?: unknown; + revision?: number; + current?: boolean; +} + +export interface ViewFollowerOptions { + owner: string; + state: string; + /** The owner's read tool as the manifest invokes it, for the log. */ + tool: string; + /** Asks the tool for the page after `after` (none: the first). */ + ask: (after?: string) => Promise; + take: (change: ViewChange) => void | Promise; + say?: (line: string) => void; + sleep?: (ms: number) => Promise; + now?: () => number; + /** How long it asks before it says so; two minutes. */ + boundMs?: number; + /** Told once when the first view stays unanswered past the bound (the module writes its finding), and once when it is answered after. */ + raise?: (asked: Date, error: string) => void | Promise; + clear?: () => void | Promise; +} + +export class ViewFollower { + readonly values = new Map(); + revision = 0; + created = ""; + ready = false; + asks = 0; + byHand = 0; + private asking = false; + private during: ViewEvent[] = []; + + private readonly o: ViewFollowerOptions; + + constructor(o: ViewFollowerOptions) { + this.o = o; + } + + private say(line: string): void { + (this.o.say ?? ((l) => console.error(`[gitea] ${l}`)))(line); + } + + /** Asks until answered: each failed ask waits, doubling to a minute, and past the bound says what waits. */ + async start(): Promise { + await this.askUntil("start"); + } + + /** Asks once more by hand, until answered (a hand act, counted). */ + async again(why: string): Promise { + this.byHand++; + await this.askUntil(`by hand: ${why}`); + } + + private async askUntil(why: string): Promise { + if (this.asking) return; // one ask at a time; the one running brings the picture to its newest + this.asking = true; + this.during = []; + const sleep = this.o.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); + const now = this.o.now ?? Date.now; + const first = now(); + let said = false; + try { + for (let wait = 5000; ; wait = Math.min(wait * 2, 60_000)) { + try { + await this.askOnce(); + if (said) { + this.say(`the first view ${this.o.tool} of ${this.o.owner}'s ${this.o.state} is answered`); + await this.o.clear?.(); + } + return; + } catch (err) { + if (!said && now() - first >= (this.o.boundMs ?? 120_000)) { + said = true; + await this.o.raise?.(new Date(first), err instanceof Error ? err.message : String(err)); + this.say(`no first view of ${this.o.owner}'s ${this.o.state}: ${this.o.tool} has not answered since ${new Date(first).toISOString()} ` + + `(${err instanceof Error ? err.message : String(err)}); it keeps asking and reads the state of nobody (${why})`); + } + await sleep(wait); + } + } + } finally { + this.asking = false; + } + } + + private async askOnce(): Promise { + this.asks++; + const values = new Map(); + let firstPage: ViewAnswer | undefined; + for (let after: string | undefined; ;) { + const a = await this.o.ask(after); + firstPage ??= a; + for (const [k, v] of Object.entries(a.values ?? {})) values.set(k, v); + if (!a.next || a.next === after) break; + after = a.next; + } + const old = new Map(this.values); + this.values.clear(); + for (const [k, v] of values) this.values.set(k, v); + this.revision = firstPage.revision; + this.created = firstPage.created; + this.ready = true; + const during = this.during.sort((a, b) => a.revision - b.revision); + this.during = []; + for (const ev of during) { + if (ev.created === this.created && ev.revision === this.revision + 1 && !ev.gap) { + if (ev.deleted) this.values.delete(ev.key); + else this.values.set(ev.key, ev.value); + this.revision = ev.revision; + } + } + for (const [k, v] of this.values) { + if (!old.has(k) || JSON.stringify(old.get(k)) !== JSON.stringify(v)) await this.o.take({ key: k, op: "put", value: v, current: true }); + } + for (const k of old.keys()) if (!this.values.has(k)) await this.o.take({ key: k, op: "delete", current: true }); + } + + /** One event of the owner's views family: taken into the picture, or a gap that asks the tool once more. */ + async hear(ev: ViewEvent): Promise { + if (ev.state !== this.o.state) return; + if (this.asking) this.during.push(ev); + if (!this.ready) return; // the first view holds it + let why = ""; + if (ev.created !== this.created) why = `${this.o.owner}'s ${this.o.state} was made again`; + else if (ev.revision <= this.revision) return; + else if (ev.revision > this.revision + 1) why = `${this.o.owner}'s ${this.o.state} at revision ${ev.revision}, the picture holds ${this.revision}`; + else if (ev.gap) why = `${this.o.owner}'s ${this.o.state}.${ev.key} was too large to carry`; + if (why) { + this.say(`a gap in ${this.o.state} (${why}): its first view ${this.o.tool} is asked once more`); + await this.askUntil(`a gap: ${why}`); + return; + } + if (ev.deleted) this.values.delete(ev.key); + else this.values.set(ev.key, ev.value); + this.revision = ev.revision; + await this.o.take({ key: ev.key, op: ev.deleted ? "delete" : "put", value: ev.value, revision: ev.revision, current: false }); + } +} + +type Tool = { name: string; description: string; input: Record; run: (args: Readonly>) => Promise }; + +/** Where a first view stands (design 25 § 12.2): the owner and its tool, the revision the picture holds, how many asks. (r) */ +export function status(f: ViewFollower, owner: string, tool: string, state: string): Record { + return { owner, tool, state, revision: f.revision, created: f.created, keys: f.values.size, answered: f.ready, asks: f.asks, "by-hand": f.byHand }; +} + +/** The reader's two tools: `gitea_first_views` says where its first views stand, `gitea_first_view_again` asks one once more by hand. */ +export function viewTools(view: () => { follower: ViewFollower; owner: string; tool: string; state: string } | undefined, + record: (act: Record) => Promise): Tool[] { + return [ + { + name: "gitea_first_views", + description: "Where each first view this module keeps stands (novox/hq ADR 0615): the owner and its tool, the revision the picture holds, how many asks (by hand among them). (r)", + input: {}, + run: async () => { + const v = view(); + return { views: v ? [status(v.follower, v.owner, v.tool, v.state)] : [] }; + }, + }, + { + name: "gitea_first_view_again", + description: "Ask the indexer's modules again by hand: a hand act, for a picture the operator doubts. Answers where it stands after. (a)", + input: {}, + run: async () => { + const v = view(); + if (!v) throw new Error("the module follows no first view yet"); + // A hand act, recorded through the seat mesh-hand-acts before it is done; one that cannot be recorded asks nothing. + try { + await record({ what: "asked a first view again by hand (gitea)", why: "the operator asked", cause: "first-view-again", verb: "first-view-again" }); + } catch (err) { + throw new Error(`the hand act was not recorded through mesh-hand-acts, so nothing was asked again: ${err instanceof Error ? err.message : String(err)}`); + } + await v.follower.again("the operator asked"); + return { views: [status(v.follower, v.owner, v.tool, v.state)] }; + }, + }, + ]; +} + +/** The key of a finding about a module: scope, the id's dotted parts, and the kind, each part made safe (mesh-sdk health.Key). */ +export function findingKey(scope: string, id: string, kind: string): string { + return [scope, ...id.split("."), kind].map((p) => p.trim().replace(/[^A-Za-z0-9_=/-]/g, "_") || "_").join("."); +} + +export const UNANSWERED_KIND = "first-view-unanswered"; + +/** A reader's finding of a first view unanswered past its bound, as mesh-sdk views.UnansweredFinding writes it (ADR 0615 §6): the + * condition keeper raises the condition from the module's state `findings`. */ +export function unansweredFinding(reader: string, owner: string, state: string, tool: string, asked: Date, error: string, at: Date): + { key: string; value: Record } { + const id = `${reader}.${owner}.${state}`; + const key = findingKey("module", id, UNANSWERED_KIND); + const summary = `${reader} has no first view of ${owner}'s ${state}: ${tool} has not answered since ${asked.toISOString()} (${error.trim()})`; + return { key, value: { + v: 1, key, kind: UNANSWERED_KIND, subject: { scope: "module", id }, severity: "warning", summary, + headline: `${reader} waits for ${owner}`, + explanation: `${reader} reads ${owner}'s ${state} only through ${tool}, and keeps asking it; it never reads the state itself. The answer clears this. ` + + `Look at ${owner}'s runtime of its own: is it running and connected?`, + resolved: "", evidence: [{ at: at.toISOString(), said: summary }], source: `${reader}.${UNANSWERED_KIND}`, observer: reader, at: at.toISOString(), + } }; +} diff --git a/modules/gitea/writes.ts b/modules/gitea/writes.ts index 552f7d585..029e4d8ba 100644 --- a/modules/gitea/writes.ts +++ b/modules/gitea/writes.ts @@ -9,9 +9,9 @@ // // **Which repositories: the indexer's word, read over the bus.** mesh-delivery-indexer keeps every registered module in its // state `modules` (mesh-sdk go/delivery ModulesBucket), each value naming the repository it builds from. This module reads -// that state (its manifest's `reads`) with a watch: the watch hands the latest value of every key when it starts, and then -// every change, so a repository is granted at the module's start and when a module of it is first registered. Nothing -// polls. A repository of the organisation needs nothing: the organisation's team writes it (tools-account.ts). Every other +// that state through the indexer's read tool (its manifest's `invokes`) and then its views events (`consumes`; ADR 0615): the +// first view hands the latest value of every key when the module starts, and then every change, so a repository is granted +// at the module's start and when a module of it is first registered. Nothing polls and the state is not read. A repository of the organisation needs nothing: the organisation's team writes it (tools-account.ts). Every other // repository of a user (the operator's dotfiles, say) is left as it is: read only. // // **Every grant is read back** from the forge (its permission for the account), and said on the bus as @@ -31,8 +31,12 @@ import type { ForgeRepository } from "./client.js"; import type { BranchProtection } from "./protection.js"; import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT } from "./pulls.js"; -/** The indexer's state this module reads: one key per registered module, its value naming the repository. */ -export const INDEXER_MODULES = "mesh-delivery-indexer.modules"; +/** The indexer, whose state `modules` this module follows: one key per registered module, its value naming the repository. + * It asks the indexer's read tool once and then hears its views events (ADR 0615), never reading the state. */ +export const INDEXER_OWNER = "mesh-delivery-indexer"; +export const INDEXER_MODULES_TOOL = `${INDEXER_OWNER}.indexer_read_modules`; +export const INDEXER_MODULES_EVENTS = `${INDEXER_OWNER}.views.modules`; +const INDEXER_MODULES = `${INDEXER_OWNER}.modules`; /** The event said once an account writes a repository it could not write before. */ export const WRITABLE_EVENT = "repo.writable"; diff --git a/modules/gnome-keyring/go.mod b/modules/gnome-keyring/go.mod index a3f0e714c..b6e68567f 100644 --- a/modules/gnome-keyring/go.mod +++ b/modules/gnome-keyring/go.mod @@ -2,10 +2,10 @@ module gnomekeyring go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/gnome-keyring/go.sum b/modules/gnome-keyring/go.sum index 02d6daae7..0daef11ff 100644 --- a/modules/gnome-keyring/go.sum +++ b/modules/gnome-keyring/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/hostname/go.mod b/modules/hostname/go.mod index 9ba71698f..3f4522f91 100644 --- a/modules/hostname/go.mod +++ b/modules/hostname/go.mod @@ -2,10 +2,10 @@ module hostname go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/hostname/go.sum b/modules/hostname/go.sum index 02d6daae7..0daef11ff 100644 --- a/modules/hostname/go.sum +++ b/modules/hostname/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/i3/go.mod b/modules/i3/go.mod index 43c563536..645d3fdbe 100644 --- a/modules/i3/go.mod +++ b/modules/i3/go.mod @@ -2,10 +2,10 @@ module i3 go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/i3/go.sum b/modules/i3/go.sum index 02d6daae7..0daef11ff 100644 --- a/modules/i3/go.sum +++ b/modules/i3/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/i3status-rust/go.mod b/modules/i3status-rust/go.mod index c47802039..7c4ae01c7 100644 --- a/modules/i3status-rust/go.mod +++ b/modules/i3status-rust/go.mod @@ -2,10 +2,10 @@ module i3statusrust go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/i3status-rust/go.sum b/modules/i3status-rust/go.sum index 02d6daae7..0daef11ff 100644 --- a/modules/i3status-rust/go.sum +++ b/modules/i3status-rust/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/kernel/go.mod b/modules/kernel/go.mod index 51c1a119a..116b868de 100644 --- a/modules/kernel/go.mod +++ b/modules/kernel/go.mod @@ -2,10 +2,10 @@ module kernel go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/kernel/go.sum b/modules/kernel/go.sum index 02d6daae7..0daef11ff 100644 --- a/modules/kernel/go.sum +++ b/modules/kernel/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/keycloak/cmd/keycloak-provider/admin_test.go b/modules/keycloak/cmd/keycloak-provider/admin_test.go index 8f9480618..23db5f0ab 100644 --- a/modules/keycloak/cmd/keycloak-provider/admin_test.go +++ b/modules/keycloak/cmd/keycloak-provider/admin_test.go @@ -8,7 +8,7 @@ package main import ( "context" "errors" - "git.novox.be/novox/mesh-sdk/go/provider/harness" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient/provider/harness" "strings" "testing" "time" diff --git a/modules/keycloak/cmd/keycloak-provider/grant_moved_test.go b/modules/keycloak/cmd/keycloak-provider/grant_moved_test.go index 0749f4f6b..74b4212c8 100644 --- a/modules/keycloak/cmd/keycloak-provider/grant_moved_test.go +++ b/modules/keycloak/cmd/keycloak-provider/grant_moved_test.go @@ -13,9 +13,9 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/provider" - "git.novox.be/novox/mesh-sdk/go/provider/harness" - "git.novox.be/novox/mesh-sdk/go/provisions" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient/provider" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient/provider/harness" ) // grantGives writes a grant file (MESH_RECEIVES) naming consumers the plans do not move (issue 937): each one's @@ -49,22 +49,22 @@ func noOpPlan(bus *bucketBus, node, module string) { // keycloak on novox the key the seat composes, asked (novox/hq ADR 0570 clauses 1 to 3). func seatPlan(bus *bucketBus, node, module string, plan map[string]any) { raw, _ := json.Marshal(map[string]any{"v": 1, "node": node, "module": module, "plan": plan}) - _ = bus.Put(ctx, provisions.PlansBucket, node+"."+module, raw) + _ = bus.Put(ctx, meshprovisionerclient.PlansBucketOf(provisionerModule), node+"."+module, raw) list, _ := plan["provisions"].([]any) for _, e := range list { entry := e.(map[string]any) name, _ := entry["provision"].(string) local, _ := entry["local"].(string) - key, err := provisions.Key(node, module, name, local) + key, err := meshprovisionerclient.Key(node, module, name, local) if err != nil { panic(err) } - asked, err := provisions.Encode(provisions.Provision{Provision: name, Local: local, State: provisions.Asked, - Consumer: provisions.End{Node: node, Module: module}, Provider: provisions.End{Node: "novox", Module: "keycloak"}}) + asked, err := meshprovisionerclient.Encode(meshprovisionerclient.Provision{Provision: name, Local: local, State: meshprovisionerclient.Asked, + Consumer: meshprovisionerclient.End{Node: node, Module: module}, Provider: meshprovisionerclient.End{Node: "novox", Module: "keycloak"}}) if err != nil { panic(err) } - _ = bus.Put(ctx, provisions.ProvisionsBucket, key, asked) + _ = bus.Put(ctx, meshprovisionerclient.ProvisionsBucketOf(provisionerModule), key, asked) } } @@ -103,7 +103,7 @@ func movedWorld(t *testing.T, h *harness.Harness) (f *fakeKeycloak, start func() } bus := &bucketBus{buckets: map[string]map[string][]byte{}} noOpPlan(bus, "elsewhere", "unrelated") - p := provider.New(provider.Options{Node: h.Node, Module: "keycloak", Bus: bus, + p := provider.New(provider.Options{Provisioner: provisionerModule, Node: h.Node, Module: "keycloak", Bus: bus, Vault: provider.ConsumersFile{Paths: []string{consumersFile}}, Offers: map[string]provider.Offer{ProvisionName: {Credential: provider.PerConsumer, Serves: servedValues(testIssuer)}}, Make: run.Make, Gone: run.Gone, Every: h.Every, Logf: func(string, ...any) {}}) diff --git a/modules/keycloak/cmd/keycloak-provider/main.go b/modules/keycloak/cmd/keycloak-provider/main.go index 81b274bca..84673b43c 100644 --- a/modules/keycloak/cmd/keycloak-provider/main.go +++ b/modules/keycloak/cmd/keycloak-provider/main.go @@ -8,7 +8,7 @@ package main import ( "context" - "git.novox.be/novox/mesh-sdk/go/provider/harness" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient/provider/harness" "os" "time" diff --git a/modules/keycloak/cmd/keycloak-provider/moved.go b/modules/keycloak/cmd/keycloak-provider/moved.go index 12fec0a7b..b0fa8234a 100644 --- a/modules/keycloak/cmd/keycloak-provider/moved.go +++ b/modules/keycloak/cmd/keycloak-provider/moved.go @@ -9,8 +9,8 @@ package main // retirement to the harness's guards (the SDK's harness retirement): the client is disabled and marked to delete, never removed. import ( - "git.novox.be/novox/mesh-sdk/go/provider" - "git.novox.be/novox/mesh-sdk/go/provider/harness" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient/provider" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient/provider/harness" ) // ProvisionName is what keycloak provides, as its manifest says. @@ -50,9 +50,13 @@ func valuesOf(e provider.Entry) map[string]any { // them. Each client it gives is made and checked by the harness's grant pass exactly as on the grant path; a client // a plan names is the helper's alone. func moved(consumersFile string, h *harness.Harness, issuer string) (*harness.Moved, *provider.Provider, provider.Bus, error) { - return harness.NewMoved(consumersFile, h, harness.MovedSpec{Module: "keycloak", Provision: ProvisionName, + return harness.NewMoved(consumersFile, h, harness.MovedSpec{Module: "keycloak", Provisioner: provisionerModule, Provision: ProvisionName, Offer: provider.Offer{Credential: provider.PerConsumer, Serves: servedValues(issuer)}, Login: func(e provider.Entry) string { return e.As }, Values: valuesOf, Noun: "client id", Made: "client", Backend: "Keycloak", WithGrants: true}) } + +// provisionerModule is the holder of the seat mesh-provisioner, named from this module's manifest (its `reads` of plans and +// provisions; TestTheProvisionerIsTheManifests). +const provisionerModule = "mesh-provisioner" diff --git a/modules/keycloak/cmd/keycloak-provider/moved_test.go b/modules/keycloak/cmd/keycloak-provider/moved_test.go index 2e47f7868..9025ea1b2 100644 --- a/modules/keycloak/cmd/keycloak-provider/moved_test.go +++ b/modules/keycloak/cmd/keycloak-provider/moved_test.go @@ -10,8 +10,8 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/provider" - "git.novox.be/novox/mesh-sdk/go/provider/harness" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient/provider" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient/provider/harness" ) const testIssuer = "https://id.example.org/realms/Novox" @@ -158,7 +158,7 @@ func TestBothPathsLeaveTheSameClientInKeycloak(t *testing.T) { seatPlan(bus, "home", "grafana", map[string]any{ "provisions": []any{map[string]any{"provision": ProvisionName, "provider": "novox", "provider-module": "keycloak", "contributes": grafanaContributes, "names": grafanaNames}}}) - pr := provider.New(provider.Options{Node: "novox", Module: "keycloak", Bus: bus, + pr := provider.New(provider.Options{Provisioner: provisionerModule, Node: "novox", Module: "keycloak", Bus: bus, Vault: provider.ConsumersFile{Paths: []string{file}}, Offers: map[string]provider.Offer{ProvisionName: {Credential: provider.PerConsumer, Serves: servedValues(testIssuer)}}, diff --git a/modules/keycloak/cmd/keycloak-provider/oidc.go b/modules/keycloak/cmd/keycloak-provider/oidc.go index ca702b0a8..209df2f01 100644 --- a/modules/keycloak/cmd/keycloak-provider/oidc.go +++ b/modules/keycloak/cmd/keycloak-provider/oidc.go @@ -35,7 +35,7 @@ import ( "strings" "time" - "git.novox.be/novox/mesh-sdk/go/provider/harness" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient/provider/harness" ) // Mark is the attribute marking a client as the mesh's own work. diff --git a/modules/keycloak/cmd/keycloak-provider/oidc_test.go b/modules/keycloak/cmd/keycloak-provider/oidc_test.go index 4d8f52d00..d3931fc29 100644 --- a/modules/keycloak/cmd/keycloak-provider/oidc_test.go +++ b/modules/keycloak/cmd/keycloak-provider/oidc_test.go @@ -7,7 +7,7 @@ package main import ( "encoding/json" - "git.novox.be/novox/mesh-sdk/go/provider/harness" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient/provider/harness" "reflect" "strings" "testing" diff --git a/modules/keycloak/cmd/keycloak-provider/provisioner.go b/modules/keycloak/cmd/keycloak-provider/provisioner.go index 6bf4aef62..cfdf884ab 100644 --- a/modules/keycloak/cmd/keycloak-provider/provisioner.go +++ b/modules/keycloak/cmd/keycloak-provider/provisioner.go @@ -16,7 +16,7 @@ import ( "context" "errors" "fmt" - "git.novox.be/novox/mesh-sdk/go/provider/harness" + "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient/provider/harness" "os" "time" ) diff --git a/modules/keycloak/cmd/keycloak-provider/provisioner_names_test.go b/modules/keycloak/cmd/keycloak-provider/provisioner_names_test.go new file mode 100644 index 000000000..9a3325a0d --- /dev/null +++ b/modules/keycloak/cmd/keycloak-provider/provisioner_names_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "encoding/json" + "os" + "slices" + "testing" +) + +// The module holding the seat mesh-provisioner, which this provider reads the plans and provisions of, is the one its manifest's +// `reads` name: no client names a holder (novox/hq ADR 0625 §4). +func TestTheProvisionerIsTheManifests(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Reads []string `json:"reads"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, state := range []string{"plans", "provisions"} { + if !slices.Contains(m.Reads, provisionerModule+"."+state) { + t.Errorf("the provider reads %s.%s, its manifest's reads are %v", provisionerModule, state, m.Reads) + } + } +} diff --git a/modules/keycloak/go.mod b/modules/keycloak/go.mod index ed2ba8c4d..614b894e7 100644 --- a/modules/keycloak/go.mod +++ b/modules/keycloak/go.mod @@ -2,10 +2,11 @@ module keycloak go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( - github.com/klauspost/compress v1.20.0 // indirect + git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient v0.0.0-00010101000000-000000000000 + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/keycloak/go.sum b/modules/keycloak/go.sum index 02d6daae7..0daef11ff 100644 --- a/modules/keycloak/go.sum +++ b/modules/keycloak/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/lab/README.md b/modules/lab/README.md index 6ce0f8d5a..87bb7bd9b 100644 --- a/modules/lab/README.md +++ b/modules/lab/README.md @@ -1,6 +1,16 @@ # lab -The `lab` module of the mesh catalogue: installs the package `git`; installs the package `make`; installs the package `python`; installs the package `file`; installs the package `iproute2`; installs the package `incus`; requires `go-toolchain` and `node-toolchain`, so the `go` module and the `nodejs` module are assigned to a machine running it (novox/hq ADR 0477 clause 4). +The `lab` module of the mesh catalogue: installs the package `git`; installs the package `openssh`; installs the package `make`; installs the package `python`; installs the package `file`; installs the package `iproute2`; installs the package `incus`; requires `go-toolchain` and `node-toolchain`, so the `go` module and the `nodejs` module are assigned to a machine running it (novox/hq ADR 0477 clause 4). + +## How its runs clone + +A run clones the mesh's repositories, private ones too (mesh-tools-go), as the build modules do: the lab requires +`forge-account`, the forge's provider registers the account's key (`~/.ssh/mesh_ed25519`) for it, and the mesh writes +`forge.gitconfig`, `forge.ssh` and `forge.known_hosts` into the lab's state from that binding. Every clone reads +`forge.gitconfig` (`git -c include.path=...`), which sends the forge's address, as the lab's `forge` setting or the +forge itself names it, over ssh to the forge with that key and only the forge's host key. No password is bound, +sealed or written for a run. npm reads from `npm-package-registry` are anonymous (novox/hq ADR 0346): the lab +contributes nothing to it and holds no secret of it; its binding gives the registry's public root only. ## Files it does not know diff --git a/modules/lab/files/forge.gitconfig b/modules/lab/files/forge.gitconfig new file mode 100644 index 000000000..d61dc5377 --- /dev/null +++ b/modules/lab/files/forge.gitconfig @@ -0,0 +1,5 @@ +[url "ssh://${bound:forge-account:ssh_alias}/"] + insteadOf = ${bound:forge-account:https_base} + insteadOf = ${config:forge}/ +[core] + sshCommand = ssh -F ${dir:state}/forge.ssh diff --git a/modules/lab/files/forge.known_hosts b/modules/lab/files/forge.known_hosts new file mode 100644 index 000000000..e0df6d811 --- /dev/null +++ b/modules/lab/files/forge.known_hosts @@ -0,0 +1 @@ +${bound:forge-account:ssh_alias} ${bound:forge-account:host_key} diff --git a/modules/lab/files/forge.ssh b/modules/lab/files/forge.ssh new file mode 100644 index 000000000..8723cbf2e --- /dev/null +++ b/modules/lab/files/forge.ssh @@ -0,0 +1,10 @@ +Host ${bound:forge-account:ssh_alias} + HostName ${bound:forge-account:ssh_host} + Port ${bound:forge-account:ssh_port} + HostKeyAlias ${bound:forge-account:ssh_alias} + User git + IdentityFile ~/.ssh/mesh_ed25519 + IdentitiesOnly yes + UserKnownHostsFile ${dir:state}/forge.known_hosts + StrictHostKeyChecking yes + BatchMode yes diff --git a/modules/lab/files/lab.env b/modules/lab/files/lab.env index 35aa1a293..6b0a8e771 100644 --- a/modules/lab/files/lab.env +++ b/modules/lab/files/lab.env @@ -1,3 +1,3 @@ MESH_LAB_FORGE=${config:forge} MESH_LAB_FORGE_BINDING=${dir:state}/package-registry.json -MESH_LAB_FORGE_SECRET=${dir:state}/package-registry.secret +MESH_LAB_FORGE_GIT=${dir:state}/forge.gitconfig diff --git a/modules/lab/module.json b/modules/lab/module.json index ef82b87c4..580bc77e2 100644 --- a/modules/lab/module.json +++ b/modules/lab/module.json @@ -1,26 +1,20 @@ { "module": "lab", "version": "1", + "moved": true, "capabilities": [ "container-runtime", "virtualisation" ], "requires": [ "npm-package-registry", + "forge-account", "go-toolchain", "node-toolchain" ], - "contributes": { - "npm-package-registry": { - "access": "read" - } - }, "binds": { "npm-package-registry": "${dir:state}/package-registry.json" }, - "secrets": { - "npm-package-registry": "${dir:state}/package-registry.secret" - }, "data": { "own": [ { @@ -61,11 +55,37 @@ "mode": "0600", "template": "files/lab.env" }, + { + "id": "forge-git", + "type": "file", + "path": "${dir:state}/forge.gitconfig", + "mode": "0600", + "template": "files/forge.gitconfig" + }, + { + "id": "forge-ssh", + "type": "file", + "path": "${dir:state}/forge.ssh", + "mode": "0600", + "template": "files/forge.ssh" + }, + { + "id": "forge-known-hosts", + "type": "file", + "path": "${dir:state}/forge.known_hosts", + "mode": "0644", + "template": "files/forge.known_hosts" + }, { "id": "git", "type": "package", "package": "git" }, + { + "id": "openssh", + "type": "package", + "package": "openssh" + }, { "id": "make", "type": "package", @@ -108,7 +128,7 @@ "MESH_LAB_WORK": "${dir:work}", "MESH_LAB_ENV_FILE": "${dir:state}/lab.env", "MESH_LAB_FORGE_BINDING": "${dir:state}/package-registry.json", - "MESH_LAB_FORGE_SECRET": "${dir:state}/package-registry.secret" + "MESH_LAB_FORGE_GIT": "${dir:state}/forge.gitconfig" } }, { diff --git a/modules/lab/test/base.test.ts b/modules/lab/test/base.test.ts index 93a6f8ecf..36d533318 100644 --- a/modules/lab/test/base.test.ts +++ b/modules/lab/test/base.test.ts @@ -60,10 +60,10 @@ test("no run starts while the base image is rebuilt", () => { }); test("a rebuild is mesh-lab's base build from the forge's main, with no secret in its script", () => { - const s = baseScript("/work/base-x", "https://forge.invalid", "/work/.base-x.git-credentials"); - assert.match(s, /clone --quiet --depth 1 'https:\/\/forge.invalid\/novox\/mesh-lab.git'/); + const s = baseScript("/work/base-x", "https://forge.invalid", "/state/forge.gitconfig"); + assert.match(s, /git -c include\.path='\/state\/forge\.gitconfig' clone --quiet --depth 1 'https:\/\/forge.invalid\/novox\/mesh-lab.git'/); assert.match(s, /src\/cli.ts base build/); - assert.ok(s.indexOf("rm -f '/work/.base-x.git-credentials'") < s.indexOf("npm ci"), "the credential outlives the clone"); + assert.doesNotMatch(s, /credential|^trap |rm -f '\/state/m, "the forge account's configuration is the mesh's, never removed by a rebuild"); }); test("a rebuild is known by its status, and is not a run", () => { diff --git a/modules/lab/test/runs.test.ts b/modules/lab/test/runs.test.ts index b20712080..9557ba6f0 100644 --- a/modules/lab/test/runs.test.ts +++ b/modules/lab/test/runs.test.ts @@ -5,7 +5,7 @@ import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { installPlan, installStep, NPM_REPOSITORIES, outcomeOf, readStatus, REPOSITORIES, script } from "../tools/runs.ts"; +import { baseScript, installPlan, installStep, NPM_REPOSITORIES, outcomeOf, readStatus, REPOSITORIES, script } from "../tools/runs.ts"; // Defends novox/hq issue 624: lab_run ran `npm ci` at the root of every checkout it installs, and // mesh-tools keeps its package in node-tools/ with no lock file at its root, so every run failed at its @@ -100,101 +100,110 @@ test("a run checks out mesh-tools-go and says where its checkouts are", () => { }); // Defends novox/hq issue 624, its second half: mesh-tools-go is private, and the lab cloned over HTTP with -// no credential ("could not read Username"). A run clones with the forge credential the mesh binds and -// seals to the lab, through git's credential store, and the secret is in no script, URL or argument. -import { credentialArgs, credentialPath, forgeCredential } from "../tools/runs.ts"; +// no credential ("could not read Username"). The lab clones as the build modules do (issue 1966): it requires +// the forge account, the mesh writes the account's git and ssh configuration into the lab's state, and a run's +// clones go through it over ssh with the account's key. No password is bound, sealed or written for a run. +import { cleanCredentials, forgeArgs, forgeConfig } from "../tools/runs.ts"; -function aBinding(as: string, secret: string): { binding: string; secret: string } { - const dir = mkdtempSync(join(tmpdir(), "lab-binding-")); - writeFileSync(join(dir, "package-registry.json"), JSON.stringify({ provision: "npm-package-registry", at: "10.42.0.1", as, - serves: { scheme: "http", port: 3000 } })); - writeFileSync(join(dir, "package-registry.secret"), `${secret}\n`); - return { binding: join(dir, "package-registry.json"), secret: join(dir, "package-registry.secret") }; +const labDir = join(import.meta.dirname, ".."); + +/** The lab's forge files as the mesh writes them for a bound forge account, into a state directory of its own. */ +function renderedForgeFiles(forge: string): string { + const state = mkdtempSync(join(tmpdir(), "lab-forge-")); + const values: Record = { + ssh_alias: "mesh-forge", ssh_host: "forge.invalid", ssh_port: "222", host_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOn", + https_base: "https://git.example.org/", + }; + for (const f of ["forge.gitconfig", "forge.ssh", "forge.known_hosts"]) { + const text = readFileSync(join(labDir, "files", f), "utf8") + .replace(/\$\{bound:forge-account:([a-z_]+)\}/g, (_, k: string) => values[k] ?? ``) + .replace(/\$\{config:forge\}/g, forge).replace(/\$\{dir:state\}/g, state); + writeFileSync(join(state, f), text); + } + return state; } -test("the forge credential is the bound account and its sealed secret, for the forge's own origin", () => { - const b = aBinding("lab-shanks", "s3cret-of-forty-characters"); - assert.equal(forgeCredential("http://novox.internal:20000", b.binding, b.secret), - "http://lab-shanks:s3cret-of-forty-characters@novox.internal:20000"); - assert.equal(forgeCredential("http://novox.internal:20000/", b.binding, ""), undefined); - assert.equal(forgeCredential("", b.binding, b.secret), undefined); - assert.equal(forgeCredential("http://novox.internal:20000", "/nowhere", b.secret), undefined); -}); - -test("a run clones through git's credential store, and its script carries no secret", () => { - const b = aBinding("lab-shanks", "s3cret-of-forty-characters"); - const credential = forgeCredential("http://novox.internal:20000", b.binding, b.secret); - const path = credentialPath("/work", "run-x"); - const s = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {}, path); - assert.ok(credential); - assert.doesNotMatch(s, /s3cret/); - assert.match(s, /git -c credential\.helper= -c 'credential\.helper=store --file=\/work\/\.run-x\.git-credentials' clone --quiet --depth 50 --branch 'main' 'http:\/\/novox\.internal:20000\/novox\/mesh-tools-go\.git'/); - assert.match(s, /export GIT_TERMINAL_PROMPT=0/); - assert.equal(path.startsWith("/work/run-x/"), false, "the credential is kept out of the run's own directory"); - assert.equal(credentialArgs(undefined), ""); -}); - -// Held against git itself: the credential in the store is offered to the forge's origin when it asks, and -// a clone without it fails at once rather than prompting. -test("git presents the stored credential to the forge, and only when one is given", () => { - const dir = mkdtempSync(join(tmpdir(), "lab-git-")); - const store = join(dir, "git-credentials"); - writeFileSync(store, "http://lab-shanks:pw@forge.invalid:20000\n", { mode: 0o600 }); - const fill = spawnSync("sh", ["-c", `printf 'protocol=http\\nhost=forge.invalid:20000\\n\\n' | git ${credentialArgs(store)}credential fill`], - { encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }); - assert.match(fill.stdout, /username=lab-shanks/); - assert.match(fill.stdout, /password=pw/); - const other = spawnSync("sh", ["-c", `printf 'protocol=http\\nhost=elsewhere.invalid\\n\\n' | git ${credentialArgs(store)}credential fill`], - { encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }); - assert.doesNotMatch(other.stdout, /password=pw/); - rmSync(dir, { recursive: true, force: true }); -}); - -// Review 196 on mesh-catalog #272: the credential file outlived a stop, because SIGTERM to the run's -// process group killed the wrapper before its cleanup. It is now removed right after the clones, on -// every way the script ends, and at the next run's start and in lab_check for what a crash left. -import { cleanCredentials, credentialGuard } from "../tools/runs.ts"; - -test("a stopped script removes the credential, whatever ends it", () => { - for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"] as const) { - const dir = mkdtempSync(join(tmpdir(), "lab-guard-")); - const file = join(dir, ".run-x.git-credentials"); - writeFileSync(file, "http://u:p@h\n", { mode: 0o600 }); - const ran = spawnSync("bash", ["-c", `${credentialGuard(file)}\nkill -${signal.slice(3)} $$\nsleep 5`], { encoding: "utf8", timeout: 10_000 }); - assert.notEqual(ran.status, 0, `${signal}: the script was not stopped`); - assert.equal(existsSync(file), false, `${signal} left the credential on disk`); - rmSync(dir, { recursive: true, force: true }); +test("the lab requires the forge account for its clones, and holds no registry credential", () => { + const m = JSON.parse(readFileSync(join(labDir, "module.json"), "utf8")) as { + moved?: boolean; requires: string[]; contributes?: unknown; secrets?: unknown; resources: { id: string; path?: string; template?: string; package?: string }[]; + build: { artifacts: { name: string; env?: Record }[] }; + }; + assert.ok(m.requires.includes("forge-account") && m.requires.includes("npm-package-registry")); + assert.equal(m.moved, true, "its files are templates filled from the forge-account binding by the node-engine, which a module that has moved asks for"); + assert.equal(m.contributes, undefined, "npm-package-registry reads are anonymous: nothing is contributed"); + assert.equal(m.secrets, undefined, "no registry secret is sealed to the lab"); + const env = m.build.artifacts.find((a) => a.name === "code")?.env ?? {}; + assert.equal(env.MESH_LAB_FORGE_SECRET, undefined); + assert.equal(env.MESH_LAB_FORGE_GIT, m.resources.find((r) => r.id === "forge-git")?.path); + assert.match(readFileSync(join(labDir, "files", "lab.env"), "utf8"), /^MESH_LAB_FORGE_GIT=\$\{dir:state\}\/forge\.gitconfig$/m); + assert.doesNotMatch(readFileSync(join(labDir, "files", "lab.env"), "utf8"), /SECRET/); + assert.ok(m.resources.some((r) => r.package === "openssh"), "the clones need ssh"); + for (const id of ["forge-git", "forge-ssh", "forge-known-hosts"]) { + const r = m.resources.find((x) => x.id === id); + assert.match(readFileSync(join(labDir, String(r?.template)), "utf8"), /\$\{bound:forge-account:/, id); } }); -test("the script removes the credential as soon as its clones are done", () => { - const s = script("/work", "run-x", "http://forge", ["t.test.ts"], {}, "/work/.run-x.git-credentials"); - const lastClone = s.lastIndexOf("clone --quiet"); - const removed = s.indexOf("rm -f '/work/.run-x.git-credentials'", lastClone); - const building = s.indexOf("npm ci"); - assert.ok(lastClone > 0 && removed > lastClone && removed < building, "not removed between the clones and the build"); - assert.match(s, /trap 'rm -f '\\''\/work\/\.run-x\.git-credentials'\\''' EXIT/); +test("a run clones through the forge account's git configuration, and its script holds no credential", () => { + const s = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {}, "/state/forge.gitconfig"); + assert.match(s, /git -c include\.path='\/state\/forge\.gitconfig' clone --quiet --depth 50 --branch 'main' 'http:\/\/novox\.internal:20000\/novox\/mesh-tools-go\.git'/); + assert.match(s, /export GIT_TERMINAL_PROMPT=0/); + assert.doesNotMatch(s, /credential|^trap /m); + assert.match(baseScript("/work/base-x", "http://forge", "/state/forge.gitconfig"), /git -c include\.path='\/state\/forge\.gitconfig' clone/); + assert.equal(forgeArgs(undefined), ""); }); -test("credentials a crashed run left are removed, but not the running one's", () => { +test("the forge account's configuration is taken only when the mesh wrote it", () => { + const state = renderedForgeFiles("http://novox.internal:20000"); + assert.equal(forgeConfig(join(state, "forge.gitconfig")), join(state, "forge.gitconfig")); + assert.equal(forgeConfig(join(state, "nowhere")), undefined); + assert.equal(forgeConfig(undefined), undefined); + writeFileSync(join(state, "empty"), ""); + assert.equal(forgeConfig(join(state, "empty")), undefined); + rmSync(state, { recursive: true, force: true }); +}); + +function withoutSsh(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const { GIT_SSH_COMMAND: _c, GIT_SSH: _s, ...rest } = env; + return rest; +} + +// Held against git and ssh themselves: a clone of the forge's address, as the lab's setting names it or as the +// forge names itself, goes over ssh to the forge account's host with the account's key and the forge's host key. +test("git sends a clone of the forge's address over ssh, as the forge account's configuration says", () => { + const state = renderedForgeFiles("http://novox.internal:20000"); + const bin = mkdtempSync(join(tmpdir(), "lab-ssh-")); + writeFileSync(join(bin, "ssh"), `#!/bin/sh\nprintf '%s\\n' "$@" >> ${JSON.stringify(join(bin, "asked"))}\nexit 1\n`, { mode: 0o755 }); + for (const url of ["http://novox.internal:20000/novox/mesh-tools-go.git", "https://git.example.org/novox/mesh-tools-go.git"]) { + rmSync(join(bin, "asked"), { force: true }); + spawnSync("sh", ["-c", `git ${forgeArgs(join(state, "forge.gitconfig"))}ls-remote ${url}`], + { encoding: "utf8", env: { ...withoutSsh(process.env), PATH: `${bin}:${process.env.PATH}`, GIT_TERMINAL_PROMPT: "0" } }); + const asked = readFileSync(join(bin, "asked"), "utf8").split("\n"); + assert.ok(asked.includes("-F") && asked.includes(join(state, "forge.ssh")), `${url}: ${asked.join(" ")}`); + assert.ok(asked.includes("mesh-forge"), url); + assert.ok(asked.some((a) => a.includes("novox/mesh-tools-go.git")), url); + } + const g = spawnSync("ssh", ["-G", "-F", join(state, "forge.ssh"), "mesh-forge"], { encoding: "utf8" }); + for (const line of ["hostname forge.invalid", "port 222", "user git", "identitiesonly yes", "batchmode yes", + "stricthostkeychecking true", `userknownhostsfile ${join(state, "forge.known_hosts")}`, "hostkeyalias mesh-forge"]) { + assert.ok(g.stdout.split("\n").includes(line), `ssh -G lacks ${line}`); + } + assert.match(g.stdout, /^identityfile ~\/\.ssh\/mesh_ed25519$/m); + assert.equal(readFileSync(join(state, "forge.known_hosts"), "utf8"), "mesh-forge ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOn\n"); + rmSync(state, { recursive: true, force: true }); + rmSync(bin, { recursive: true, force: true }); +}); + +// What a run of before left on disk, its credential line, is removed at the next run's start and in lab_check. +test("credentials an earlier run left are removed", () => { const work = mkdtempSync(join(tmpdir(), "lab-work-")); - for (const n of [".run-a.git-credentials", ".run-b.git-credentials", "run-a", "other"]) writeFileSync(join(work, n), "x"); - const removed = cleanCredentials(work, join(work, ".run-b.git-credentials")); - assert.deepEqual(removed, [join(work, ".run-a.git-credentials")]); - assert.ok(existsSync(join(work, ".run-b.git-credentials")) && existsSync(join(work, "run-a")) && existsSync(join(work, "other"))); + for (const n of [".run-a.git-credentials", ".base-b.git-credentials", "run-a", "other"]) writeFileSync(join(work, n), "x"); + const removed = cleanCredentials(work).sort(); + assert.deepEqual(removed, [join(work, ".base-b.git-credentials"), join(work, ".run-a.git-credentials")]); + assert.ok(existsSync(join(work, "run-a")) && existsSync(join(work, "other"))); rmSync(work, { recursive: true, force: true }); }); -// The lab asks the forge for read only (review 196): its credential is for clones of branches nobody has -// reviewed yet, and must not be able to publish the mesh's packages. -test("the lab asks the package registry for read only", () => { - const m = JSON.parse(readFileSync(join(import.meta.dirname, "..", "module.json"), "utf8")) as { - requires: unknown[]; contributes?: Record>; - }; - assert.ok(m.requires.includes("npm-package-registry")); - assert.equal(m.contributes?.["npm-package-registry"]?.access, "read"); -}); - // lab_check failed for ever on "destination path '/check' already exists": it cloned into one fixed // path and ignored whether removing the last one worked (novox/hq issue 624). Each check now works in a // fresh directory, and what earlier checks left is cleared or named. @@ -239,19 +248,6 @@ test("leftover checks are cleared, whoever runs the check", () => { rmSync(work, { recursive: true, force: true }); }); -// The lab's tools read the bound credential, so the runtime is given that file by the tools bundle's env -// (ADR 0192), as gitea and umami are given theirs: the runtime's account may then read exactly it, and no -// other credential changes hands (issue 624). -test("the lab's tools bundle names its bound credential and binding in its env", () => { - const m = JSON.parse(readFileSync(join(import.meta.dirname, "..", "module.json"), "utf8")) as { - secrets: Record; binds: Record; - build: { artifacts: { name: string; env?: Record }[] }; - }; - const env = m.build.artifacts.find((a) => a.name === "code")?.env ?? {}; - assert.equal(env.MESH_LAB_FORGE_SECRET, m.secrets["npm-package-registry"]); - assert.equal(env.MESH_LAB_FORGE_BINDING, m.binds["npm-package-registry"]); -}); - // The next setup failure of novox/hq issue 624 (run-2026-10-13T13-15-26-634Z on shanks): npm 12 refused // mesh-tools' locked @novox/mesh-sdk with EALLOWREMOTE. The lab named the registry by its forge setting's // address (http://novox.internal:20000/…), the lock file holds the address the forge writes every tarball diff --git a/modules/lab/tools/index.ts b/modules/lab/tools/index.ts index 63c7c41f1..20bae1150 100644 --- a/modules/lab/tools/index.ts +++ b/modules/lab/tools/index.ts @@ -4,12 +4,10 @@ import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { rmSync } from "node:fs"; import { - checkDir, cleanCredentials, clearChecks, credentialArgs, forgeCredential, installPlan, listRuns, npmRegistry, NPM_REPOSITORIES, publicRootOf, readStatus, REPOSITORIES, running, start, - stop, tail, writeCredential, baseBuildLog, buildingBase, listBaseBuilds, startBaseBuild, credentialPath, launchRun, + checkDir, cleanCredentials, clearChecks, forgeArgs, forgeConfig, installPlan, listRuns, npmRegistry, NPM_REPOSITORIES, publicRootOf, readStatus, REPOSITORIES, start, + stop, tail, baseBuildLog, listBaseBuilds, startBaseBuild, launchRun, launchRebuild, BASE_MAX_AGE_DAYS, describeBase, readBaseImage, baseProblem, } from "./runs.js"; @@ -19,7 +17,6 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] { // ADR 0192): read from the env-file the mesh fills, at each call, so a changed setting is used // without restarting the runtime. MESH_LAB_FORGE itself still wins, for a hand-run instance. const forgeOf = (): string => (env.MESH_LAB_FORGE ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE")).replace(/\/+$/, ""); - // The forge credential the mesh binds and seals to the lab (novox/hq issue 624), read at each call. // The registry's public root the forge serves in the lab's binding (novox/hq ADR 0346), read at each call. const bindingOf = (): string | undefined => env.MESH_LAB_FORGE_BINDING ?? (wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_BINDING") || undefined); @@ -28,9 +25,9 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] { const registry = await npmRegistry(forge, fetch, root.root); return registry.problem && root.problem ? { ...registry, problem: `${registry.problem}; ${root.problem}` } : registry; }; - const credentialOf = (forge: string): string | undefined => forgeCredential(forge, - env.MESH_LAB_FORGE_BINDING ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_BINDING"), - env.MESH_LAB_FORGE_SECRET ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_SECRET")); + // The forge account's git configuration the mesh writes for the lab's clones (forgeConfig), read at each call. + const forgeGitOf = (): string | undefined => + forgeConfig(env.MESH_LAB_FORGE_GIT ?? (wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_GIT") || undefined)); return [ { name: "lab_check", @@ -50,7 +47,7 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] { if (clone.status !== 0) return { ok: false, output: [String(clone.stderr).trim(), ...leftovers].join("\n") }; spawnSync("npm", ["ci", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, encoding: "utf8" }); check = spawnSync("node", ["--experimental-strip-types", "src/cli.ts", "check"], { cwd: dir, encoding: "utf8" }); - problems = setupProblems(forge, `${dir}/setup`, credentialOf(forge)); + problems = setupProblems(forge, `${dir}/setup`, forgeGitOf()); // And the address a run names the package registry by (npmRegistry): said when the registry does not say it. const registry = await registryOf(forge); if (registry.problem) problems.push(registry.problem); @@ -60,10 +57,8 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] { // **And what a run would check out and install, before a run finds out** (novox/hq issue 624): every // repository it clones answers on the forge, and every one it installs has a lock file where the run // installs from. - // A credential a stopped, crashed or rebooted run or rebuild left behind is removed, and said; one still in - // use by a running run or rebuild is not. - const busy = [running(work)?.id, buildingBase(work)?.id].filter((id): id is string => !!id); - const left = cleanCredentials(work, busy.map((id) => credentialPath(work, id))); + // A credential line a run or rebuild of before the forge account left behind is removed, and said. + const left = cleanCredentials(work); const removed = left.length > 0 ? `removed ${left.length} forge credential(s) a run or rebuild left behind: ${left.join(", ")}` : ""; // **And how old the base image is** (novox/hq issue 698): past the bound, missing, or of an age that cannot // be read is a problem here, though lab_run refuses only the first. @@ -106,7 +101,7 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] { // **One job at a time, not on a base image past its bound, nor while it is rebuilt** (novox/hq issue 698, // review 256): the registry asked first, then the check and the start with nothing awaited between. const launched = await launchRun(work, { registry: () => registryOf(forge), base: () => readBaseImage() }, - (registry) => start(work, forge, tests, refs, credentialOf(forge), registry)); + (registry) => start(work, forge, tests, refs, forgeGitOf(), registry)); if (!launched.started) return launched; return { started: true, run: launched.job, base: describeBase(launched.base), registry: launched.registry, ...(launched.problem ? { problem: launched.problem } : {}) }; @@ -137,7 +132,7 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] { // Not under a run, nor beside another rebuild: the registry asked first, then the check and the start with // nothing awaited between (review 256). const launched = await launchRebuild(work, { registry: () => registryOf(forge), base: () => before }, - (registry) => startBaseBuild(work, forge, credentialOf(forge), registry)); + (registry) => startBaseBuild(work, forge, forgeGitOf(), registry)); if (launched.started === false) return { ...reading, started: false, reason: launched.reason }; return { ...reading, started: true, build: launched.job }; }, @@ -174,14 +169,12 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] { * answer for, and a checkout it installs with nothing installable (installPlan). Empty when a run's setup * would go through. */ -export function setupProblems(forge: string, dir: string, credential?: string): string[] { +export function setupProblems(forge: string, dir: string, forgeGit?: string): string[] { const problems: string[] = []; spawnSync("rm", ["-rf", dir]); - // The credential a run would offer, in a file of its own outside the check's clones, removed after. - const keep = mkdtempSync(join(tmpdir(), "lab-check-")); - const file = writeCredential(join(keep, "git-credentials"), credential); + // The forge account's configuration a run's clones go through (forgeConfig). const git = (args: string[]) => - spawnSync("sh", ["-c", `git ${credentialArgs(file)}${args.map((a) => `'${a.replace(/'/g, `'\\''`)}'`).join(" ")}`], { + spawnSync("sh", ["-c", `git ${forgeArgs(forgeGit)}${args.map((a) => `'${a.replace(/'/g, `'\\''`)}'`).join(" ")}`], { encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, }); try { @@ -192,7 +185,7 @@ export function setupProblems(forge: string, dir: string, credential?: string): const said = remote.stderr.trim().split("\n")[0] ?? ""; problems.push(`a run checks out ${repo}, and ${url} could not be read` + (said ? `: ${said}` : ": no main branch") + - (credential ? "" : " — the lab holds no forge credential (its npm-package-registry binding and secret)")); + (forgeGit ? "" : " — the lab holds no forge account yet (its forge-account binding, written as forge.gitconfig in its state)")); continue; } if (!NPM_REPOSITORIES.includes(repo)) continue; @@ -209,7 +202,6 @@ export function setupProblems(forge: string, dir: string, credential?: string): } } } finally { - rmSync(keep, { recursive: true, force: true }); spawnSync("rm", ["-rf", dir]); } return problems; diff --git a/modules/lab/tools/runs.ts b/modules/lab/tools/runs.ts index a253c0695..b1795d420 100644 --- a/modules/lab/tools/runs.ts +++ b/modules/lab/tools/runs.ts @@ -163,68 +163,31 @@ export async function npmRegistry(forge: string, get: typeof fetch = fetch, publ } /** - * The forge credential a run's clones may present, as a git credential-store line — - * scheme://user:password@host[:port] — for the forge's own origin, or undefined when the lab holds none. + * The forge account's git configuration a run's clones go through, or undefined when the mesh has not written it. * - * **The mesh's credential for the forge, the way the build modules hold it** (novox/hq issue 624): the lab - * requires the package registry, the forge's provisioner binds it an account (`as`) and seals that - * account's password, and the forge that answers npm is the forge that hosts the repositories. Private - * repositories (mesh-tools-go) are cloned with it; public ones clone as before. Written to a file git reads - * through its credential store, never into a URL, an argument, the run's script or its log, and offered - * only to the forge's own scheme, host and port. + * **The mesh's account on the forge, the way the build modules hold it** (novox/hq issue 624, issue 1966): the lab + * requires `forge-account`, the forge's provider registers the account's key (`~/.ssh/mesh_ed25519`) for it, and + * the mesh writes into the lab's state a git configuration that sends every clone of the forge's address over ssh + * to the forge, with an ssh configuration that offers that key and takes only the forge's host key. Private + * repositories (mesh-tools-go) clone with it; no password is bound, sealed or written for a run. */ -export function forgeCredential(forge: string, bindingFile: string, secretFile: string): string | undefined { - if (!forge || !bindingFile || !secretFile) return undefined; - let as = ""; - let secret = ""; +export function forgeConfig(file: string | undefined): string | undefined { + if (!file) return undefined; try { - as = String((JSON.parse(readFileSync(bindingFile, "utf8")) as { as?: string }).as ?? ""); - secret = readFileSync(secretFile, "utf8").trim(); + return /^\s*insteadOf\s*=\s*\S/m.test(readFileSync(file, "utf8")) ? file : undefined; } catch { return undefined; } - if (!as || !secret) return undefined; - let origin: URL; - try { - origin = new URL(forge); - } catch { - return undefined; - } - const line = new URL(`${origin.protocol}//${origin.host}`); - line.username = as; - line.password = secret; - return line.toString().replace(/\/$/, ""); } -/** Where a run keeps its forge credential: beside its directory, never in it, and removed when it ends. */ +/** Where a run of before kept its forge credential line, beside its directory: removed wherever one is left. */ export function credentialPath(work: string, id: string): string { return join(work, `.${id}.git-credentials`); } -/** writeCredential puts a credential-store line where git reads it, readable by this account alone. */ -export function writeCredential(path: string, line: string | undefined): string | undefined { - if (!line) return undefined; - writeFileSync(path, `${line}\n`, { mode: 0o600 }); - return path; -} - -/** - * credentialGuard is the shell that removes the credential however the script ends — its end, an error, - * or a stop's SIGTERM to the run's process group, which kills the wrapper before its own cleanup runs - * (review 196 on mesh-catalog #272). The script also removes it as soon as its clones are done. - */ -export function credentialGuard(path: string | undefined): string { - if (!path) return ""; - const rm = `rm -f ${shellQuote(path)}`; - return `trap ${shellQuote(rm)} EXIT -trap ${shellQuote(`${rm}; exit 143`)} TERM -trap ${shellQuote(`${rm}; exit 130`)} INT -trap ${shellQuote(`${rm}; exit 129`)} HUP`; -} - /** * cleanCredentials removes every run's and every base rebuild's credential file left in the work directory - * but the ones named — what a crash or a reboot left behind, which no trap could remove. Answers what it + * but the ones named — what runs of before the forge account, a crash or a reboot left behind. Answers what it * removed. */ export function cleanCredentials(work: string, keep?: string | string[]): string[] { @@ -272,10 +235,10 @@ export function clearChecks(work: string): string[] { return said; } -/** The arguments that have git offer the credential in that file, and only that one. */ -export function credentialArgs(path: string | undefined): string { +/** The arguments that have git read the forge account's configuration (forgeConfig) for one command. */ +export function forgeArgs(path: string | undefined): string { if (!path) return ""; - return `-c credential.helper= -c ${shellQuote(`credential.helper=store --file=${path}`)} `; + return `-c include.path=${shellQuote(path)} `; } export interface RunStatus { @@ -462,7 +425,7 @@ const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`; * at this run's own tree, so what is built and claimed is exactly what was checked out. */ export function script(work: string, id: string, forge: string, tests: string[], refs: Record, - credential?: string, registry = `${forge}${NPM_PATH}`): string { + forgeGit?: string, registry = `${forge}${NPM_PATH}`): string { const dir = runDir(work, id); const setState = (state: string) => `node -e ${shellQuote( @@ -471,7 +434,7 @@ export function script(work: string, id: string, forge: string, tests: string[], const clones = REPOSITORIES.map((repo) => { const ref = refs[repo] ?? "main"; return [ - `git ${credentialArgs(credential)}clone --quiet --depth 50 --branch ${shellQuote(ref)} ${shellQuote(`${forge}/novox/${repo}.git`)} ${shellQuote(join(dir, repo))}`, + `git ${forgeArgs(forgeGit)}clone --quiet --depth 50 --branch ${shellQuote(ref)} ${shellQuote(`${forge}/novox/${repo}.git`)} ${shellQuote(join(dir, repo))}`, `echo "${repo} $(git -C ${shellQuote(join(dir, repo))} rev-parse HEAD)" >> ${shellQuote(join(dir, "commits.txt"))}`, ].join("\n"); }).join("\n"); @@ -479,11 +442,9 @@ export function script(work: string, id: string, forge: string, tests: string[], return `set -euo pipefail # A repository the forge will not show fails its clone at once, rather than waiting on a prompt. export GIT_TERMINAL_PROMPT=0 -${credentialGuard(credential)} cd ${shellQuote(dir)} ${setState("checking-out")} ${clones} -${credential ? `# The forge credential is for the clones, and nothing after them runs with it on disk.\nrm -f ${shellQuote(credential)}` : ""} node -e ${shellQuote( `const fs=require("fs");const f=${JSON.stringify(join(dir, "status.json"))};const s=JSON.parse(fs.readFileSync(f,"utf8"));s.commits=Object.fromEntries(fs.readFileSync(${JSON.stringify(join(dir, "commits.txt"))},"utf8").trim().split("\\n").map(l=>l.split(" ")));fs.writeFileSync(f,JSON.stringify(s,null,2))`, )} @@ -516,16 +477,15 @@ node --experimental-strip-types src/cli.ts suite ${tests.map(shellQuote).join(" /** start begins a run and returns at once with its status. */ export function start(work: string, forge: string, tests: string[], refs: Record, - credential?: string, registry?: string): RunStatus { + forgeGit?: string, registry?: string): RunStatus { const id = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`; const dir = runDir(work, id); mkdirSync(dir, { recursive: true }); const status: RunStatus = { id, state: "checking-out", started: new Date().toISOString(), tests, refs }; writeFileSync(statusPath(work, id), JSON.stringify(status, null, 2)); - // What a crashed or rebooted run left behind goes before this run writes its own. + // A credential line a run of before left behind goes before this run starts. cleanCredentials(work); - const credentials = writeCredential(credentialPath(work, id), credential); - writeFileSync(join(dir, "run.sh"), script(work, id, forge, tests, refs, credentials, registry), { mode: 0o700 }); + writeFileSync(join(dir, "run.sh"), script(work, id, forge, tests, refs, forgeGit, registry), { mode: 0o700 }); // The wrapper records how the run ended, then removes the checkouts and keeps the log and status: a // run's tree is its own, and the next run starts from fresh ones (novox/hq ADR 0172). @@ -533,7 +493,6 @@ export function start(work: string, forge: string, tests: string[], refs: Record node -e ${shellQuote( `const f=${JSON.stringify(statusPath(work, id))};const s=JSON.parse(require("fs").readFileSync(f,"utf8"));if(s.state!=="stopped"){s.state=process.argv[1]==="0"?"passed":"failed"};s.exit=Number(process.argv[1]);s.ended=new Date().toISOString();require("fs").writeFileSync(f,JSON.stringify(s,null,2))`, )} "$code" -rm -f ${shellQuote(credentialPath(work, id))} cd ${shellQuote(dir)} && rm -rf ${REPOSITORIES.map(shellQuote).join(" ")} bin`; const child = spawn("bash", ["-c", wrapper], { detached: true, stdio: "ignore" }); child.unref(); @@ -715,14 +674,12 @@ export function baseBuildLog(work: string, id: string, lines = 40): string { * baseScript is what one rebuild runs: mesh-lab's main from the forge, installed the way a run installs it, * then its `base build`, which replaces the image and reads back that it works before publishing it. */ -export function baseScript(dir: string, forge: string, credential?: string, registry = `${forge}${NPM_PATH}`): string { +export function baseScript(dir: string, forge: string, forgeGit?: string, registry = `${forge}${NPM_PATH}`): string { const lab = join(dir, "mesh-lab"); return `set -euo pipefail export GIT_TERMINAL_PROMPT=0 -${credentialGuard(credential)} cd ${shellQuote(dir)} -git ${credentialArgs(credential)}clone --quiet --depth 1 ${shellQuote(`${forge}/novox/mesh-lab.git`)} ${shellQuote(lab)} -${credential ? `rm -f ${shellQuote(credential)}` : ""} +git ${forgeArgs(forgeGit)}clone --quiet --depth 1 ${shellQuote(`${forge}/novox/mesh-lab.git`)} ${shellQuote(lab)} echo "mesh-lab $(git -C ${shellQuote(lab)} rev-parse HEAD)" printf '%s\\n' ${shellQuote(`@novox:registry=${registry}`)} > ${shellQuote(join(dir, ".npmrc"))} export NPM_CONFIG_USERCONFIG=${shellQuote(join(dir, ".npmrc"))} @@ -733,21 +690,19 @@ node --experimental-strip-types src/cli.ts base build } /** startBaseBuild rebuilds the base image in the background and answers at once with the build. */ -export function startBaseBuild(work: string, forge: string, credential?: string, registry?: string): BaseBuild { +export function startBaseBuild(work: string, forge: string, forgeGit?: string, registry?: string): BaseBuild { const id = `base-${new Date().toISOString().replace(/[:.]/g, "-")}`; const dir = join(work, id); mkdirSync(dir, { recursive: true }); const build: BaseBuild = { id, state: "building", started: new Date().toISOString() }; writeFileSync(buildStatusPath(work, id), JSON.stringify(build, null, 2)); - // What a crashed or rebooted run or rebuild left behind goes before this one writes its own. + // A credential line a run or rebuild of before left behind goes before this one starts. cleanCredentials(work); - const credentials = writeCredential(credentialPath(work, id), credential); - writeFileSync(join(dir, "build.sh"), baseScript(dir, forge, credentials, registry), { mode: 0o700 }); + writeFileSync(join(dir, "build.sh"), baseScript(dir, forge, forgeGit, registry), { mode: 0o700 }); const wrapper = `bash ${shellQuote(join(dir, "build.sh"))} > ${shellQuote(join(dir, "build.log"))} 2>&1; code=$? node -e ${shellQuote( `const f=${JSON.stringify(buildStatusPath(work, id))};const s=JSON.parse(require("fs").readFileSync(f,"utf8"));s.state=process.argv[1]==="0"?"built":"failed";s.exit=Number(process.argv[1]);s.ended=new Date().toISOString();require("fs").writeFileSync(f,JSON.stringify(s,null,2))`, )} "$code" -rm -f ${shellQuote(credentialPath(work, id))} rm -rf ${shellQuote(join(dir, "mesh-lab"))}`; const child = spawn("bash", ["-c", wrapper], { detached: true, stdio: "ignore" }); child.unref(); diff --git a/modules/lemurs/go.mod b/modules/lemurs/go.mod index ba2b04c5e..c2597d95a 100644 --- a/modules/lemurs/go.mod +++ b/modules/lemurs/go.mod @@ -2,10 +2,10 @@ module lemurs go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/lemurs/go.sum b/modules/lemurs/go.sum index 02d6daae7..0daef11ff 100644 --- a/modules/lemurs/go.sum +++ b/modules/lemurs/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/localization/go.mod b/modules/localization/go.mod index b89920c2d..a684820aa 100644 --- a/modules/localization/go.mod +++ b/modules/localization/go.mod @@ -2,10 +2,10 @@ module localization go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/localization/go.sum b/modules/localization/go.sum index 02d6daae7..0daef11ff 100644 --- a/modules/localization/go.sum +++ b/modules/localization/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/logrotate/go.mod b/modules/logrotate/go.mod index 0b750f9c5..80b14c11e 100644 --- a/modules/logrotate/go.mod +++ b/modules/logrotate/go.mod @@ -2,10 +2,10 @@ module logrotate go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/logrotate/go.sum b/modules/logrotate/go.sum index 02d6daae7..0daef11ff 100644 --- a/modules/logrotate/go.sum +++ b/modules/logrotate/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/memory-pressure/go.mod b/modules/memory-pressure/go.mod index 64a31736e..469e0f842 100644 --- a/modules/memory-pressure/go.mod +++ b/modules/memory-pressure/go.mod @@ -2,10 +2,10 @@ module memorypressure go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 require ( - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect diff --git a/modules/memory-pressure/go.sum b/modules/memory-pressure/go.sum index 02d6daae7..0daef11ff 100644 --- a/modules/memory-pressure/go.sum +++ b/modules/memory-pressure/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= diff --git a/modules/mesh-build-go/README.md b/modules/mesh-build-go/README.md index ce1d97989..d62555dd2 100644 --- a/modules/mesh-build-go/README.md +++ b/modules/mesh-build-go/README.md @@ -139,11 +139,30 @@ that vendors), `go build -ldflags "-s -w -X main.builtFor=" -o .mesh-bui module's directory, by the installed go (`GOTOOLCHAIN=local`) from the module cache the fetch filled and no network (`GOPROXY=off`). The binary is named as the build agent names it. The output directory is packed with the build runner's `Pack`, as the build agent packs; two build nodes on the same toolchain give the same digest for the same tree (ADR 0477 §6). The step ends with the artifact, the binary, the digest, the size and -the reference of the archive: once the build ended, this process pushes it to the artifact store (novox/hq ADR 0498 §1 §2, -the build runner's `buildstep.PushBlob`) under `/` by its digest, a candidate's as a trunk build's, and answers -`artifact-store:////blobs/`; a push that fails fails the step `not stored: : `. The packed bytes never stay on the node. The store's address is `${dir:state}/registry`, placed by the -manifest, which requires `artifact-store`. The go build runs shut off in bubblewrap (ADR 0448, `isolate.go`): no +the reference of the archive: once the build ended, this process publishes it through the seat `oci-artifact-registry` +(design 51 *Publishing*; `publish.go`): its verbs `publish` and `publish-part`, which the manifest `invokes`, with mesh-sdk's +sender (`publish.Publish`), under `/` by its digest and of kind `bundle` or `archive`, a candidate's as a +trunk build's, in chunks, only what the holder lacks; the holder asks the builder and says `published` or `refused` on the +seat's events. The step answers the seat's form `oci-artifact-registry//@` (ADR 0605 §9); the +builder writes the older form beside it while a counted node-engine reads only that (ADR 0624); a publish the holder refuses, or a +seat nothing holds, fails the step `not stored: : `. The packed bytes never stay on the node. +A build the builder numbers later is published late: the module consumes the builder's `version.numbered` and, for a +bundle or an archive with a digest, publishes that held digest under its version, naming its one part by digest and +sending nothing (`latepublish.go`); a holder that no longer holds the digest fails the event, which is offered again. Every +ask of a seat a build module makes (a publish, a late publish, an image's copy) is narrated by `buildstep/narrate.go`: +asked, waiting on the seat every five minutes while it has not answered, stored or refused, each the ask's phase on its +running step key (logged outside an ask). +A contracts module (a package artifact naming `generated-from`) has its generated Go files regenerated with the seat +generator this module is built with and compared (`buildstep/generated.go`): the unit level of `build_go_test` fails +naming each file that differs, is missing or was not written by the generator (its `out-of-step`), and a build ask +answers the same files in its answer's `out-of-step`, a failed build's too, so a trunk build's end can raise +`generated-types-out-of-step` where no test step runs. Its TypeScript files are mesh-build-node's to compare. + +The module writes into no store itself and requires no `artifact-store`. A Go package (kind `package`, language `go`; `gopackage.go`) is +the directory its one path names packed as Go's module zip, every file under `@/`, its digest the +zip's (no `h1:` sum is answered: nothing carries it on); a trunk build the builder gave a version publishes it through +`go-package-registry` under its Go module path and that version (the manifest `invokes` both verbs) and answers +`@`; a candidate's is packed under `v0.0.0` and publishes nothing. The go build runs shut off in bubblewrap (ADR 0448, `isolate.go`): no network, none of the account, the caches beneath a throwaway layer unless the step is told `publish` (a trunk build). An artifact that is neither a Go bundle nor an archive fails the step naming it. @@ -158,9 +177,16 @@ and xz, read in Go as the build agent reads it, issue 1590); it runs no command - The build runner (the pull of asks, the step lifecycle, the toolchain guard, the fetch, the parsers of `go test -json` and `node --test`, `Pack`, the conformance kit and the `mesh-pack` command) is this module's package `git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep`, a Go module of its own in `buildstep/`. mesh-build-node - and mesh-build-image require it and replace it to the sibling directory, since it is the same repository. What the delivery + and mesh-build-image require it and replace it to the sibling directory, since it is the same repository. It is declared + as this module's Go package artifact `buildstep`, series `0.1` (ADR 0606): a user in another repository accepts its + series, never a commit. What the delivery parts read of a step (the failure contract, the fetch request, the stale-step sweep, the step id) stays in mesh-sdk's `buildstep`. +- The seat generator (design 26 *Contracts modules*) is the package `buildstep/seatgen` and the command `buildstep/cmd/seatgen`, + one program writing a contracts module's Go and TypeScript (`seatgen `; `seatgen -check ` names every path out of + step and runs the client and artifact checks). Its version is buildstep's, so the build input of a compare is the + mesh-build-go (or mesh-build-node) build itself: a new generator is a new build module and a new fingerprint. A contracts + repository runs it as `go run git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/cmd/seatgen@ -check .`. - `build_go_imports` (a tool, and the last step of a build ask that built an artifact) answers the mesh packages the module's Go programs import: `go list -deps` of each Go bundle's program, tests left out, the packages of the forge outside the module's own directory (a sibling package of its own repository counts), sorted and each once. A build ask carries them as diff --git a/modules/mesh-build-go/buildstep/askrun.go b/modules/mesh-build-go/buildstep/askrun.go index 60bc385ea..6400ec15d 100644 --- a/modules/mesh-build-go/buildstep/askrun.go +++ b/modules/mesh-build-go/buildstep/askrun.go @@ -10,7 +10,8 @@ import ( "sort" "strings" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" + trunk "git.novox.be/novox/mesh-delivery/sdk/trunk" ) // **One ask, one whole job** (novox/hq ADR 0525 §3). A build module runs a pulled ask with its own step tools' bodies, @@ -29,23 +30,27 @@ type AskSteps struct { // {"imports": [...]}, sorted, from the workspace the build ran in. A build that built an artifact asks it once, at // its end; nil reports none. Imports StepFunc + // Compare regenerates a contracts module's generated files (a package artifact naming generated-from) and answers + // {"out-of-step": [...]} (CompareStep): a build ask runs it once on the fetched workspace and answers the paths in + // its out-of-step, a failed build's too, so a trunk build's end names them where no test step runs. Nil compares none. + Compare StepFunc // Toolchain is the toolchain the module builds with now; a build ask naming another is declined (Declining). Nil checks nothing // (an image or an archive names none). - Toolchain func(ctx context.Context) (delivery.ToolchainUsed, error) - // Kept looks up a kept verdict by level and digest (the checker's kept bucket, delivery.KeptKey): whether one is + Toolchain func(ctx context.Context) (sdkstep.ToolchainUsed, error) + // Kept looks up a kept verdict by level and digest (the checker's kept bucket, the checker seat's kept key): whether one is // kept and how many tests it counted. Nil takes none. Kept func(ctx context.Context, level, digest string) (tests int, found bool, err error) // Release removes a workspace once the ask's steps on it ended (the module's release tool's body); nil leaves it to // the module's sweep. Release func(workspace string) error Logf func(format string, args ...any) - // Has says whether this node has a need a package's tests declare (delivery.AskNeeds, issue 890); a package needing + // Has says whether this node has a need a package's tests declare (sdkstep.AskNeeds, issue 890); a package needing // what it lacks is not tested here but named in the level's Unmet, for the asker to place. Nil has nothing. Has func(need string) bool } -// RunAsk runs ask's job with steps and answers a delivery.BuildAnswer or a delivery.CheckAnswer. -func RunAsk(ctx context.Context, steps AskSteps, ask delivery.StepAsk) (any, error) { +// RunAsk runs ask's job with steps and answers a sdkstep.BuildAnswer or a sdkstep.CheckAnswer. +func RunAsk(ctx context.Context, steps AskSteps, ask sdkstep.StepAsk) (any, error) { if ask.Build != nil { job := *ask.Build job.Fetch = withPackages(job.Fetch, ask.Packages) @@ -53,7 +58,7 @@ func RunAsk(ctx context.Context, steps AskSteps, ask delivery.StepAsk) (any, err } if ask.Check != nil { job := *ask.Check - job.Fetch = withPackages(job.Fetch, ask.Packages) + job.Fetch = *withPackages(&job.Fetch, ask.Packages) return steps.check(ctx, job) } return nil, Refuse("the ask %s holds no job", ask.ID) @@ -62,13 +67,28 @@ func RunAsk(ctx context.Context, steps AskSteps, ask delivery.StepAsk) (any, err // withPackages is the fetch's arguments with the ask's package versions (novox/hq ADR 0573 §2), which the build module // writes into the workspace it fetches; a copy, so the ask is left as it was. An ask with none, or a copy alone that // fetches nothing, is left as it is. -func withPackages(fetch map[string]any, packages delivery.PackageVersions) map[string]any { +func withPackages(fetch *sdkstep.Fetch, packages sdkstep.PackageVersions) *sdkstep.Fetch { if len(packages) == 0 || fetch == nil { return fetch } - out := maps.Clone(fetch) - out["packages"] = maps.Clone(packages) - return out + out := *fetch + out.Packages = maps.Clone(packages) + return &out +} + +// FetchArgs is a fetch as the fetch tool takes it (sdkstep.FetchRequest's Args and the build module's own, beside): the +// ask's fetch through the one JSON form, without the layout version, which no tool takes. +func FetchArgs(f sdkstep.Fetch) (map[string]any, error) { + b, err := json.Marshal(f) + if err != nil { + return nil, err + } + args := map[string]any{} + if err := json.Unmarshal(b, &args); err != nil { + return nil, err + } + delete(args, "v") + return args, nil } func raw(v any) (json.RawMessage, error) { @@ -87,11 +107,18 @@ type fetched struct { Modules []string `json:"modules"` } -func (s AskSteps) fetch(ctx context.Context, args map[string]any) (json.RawMessage, fetched, error) { +func (s AskSteps) fetch(ctx context.Context, fetch *sdkstep.Fetch) (json.RawMessage, fetched, error) { if s.Fetch == nil { return nil, fetched{}, Refuse("this build module fetches nothing") } - if _, merges := args["base"]; merges { + if fetch == nil { + return nil, fetched{}, Refuse("the ask names no fetch") + } + args, err := FetchArgs(*fetch) + if err != nil { + return nil, fetched{}, err + } + if fetch.Base != "" { Phase(ctx, "fetch and merge") } else { Phase(ctx, "fetch") @@ -114,7 +141,7 @@ func (s AskSteps) fetch(ctx context.Context, args map[string]any) (json.RawMessa // onToolchain is the toolchain this node builds with when the ask names one and this module reports its own, nil when // either is absent. A node on another toolchain than the ask names answers a Declining: it is not on the agreed one and // sits out, for a node that is to take the ask (novox/hq ADR 0584 clause 2). -func (s AskSteps) onToolchain(ctx context.Context, want *delivery.ToolchainUsed) (*delivery.ToolchainUsed, error) { +func (s AskSteps) onToolchain(ctx context.Context, want *sdkstep.ToolchainUsed) (*sdkstep.ToolchainUsed, error) { if want == nil || s.Toolchain == nil { return nil, nil } @@ -130,8 +157,8 @@ func (s AskSteps) onToolchain(ctx context.Context, want *delivery.ToolchainUsed) return &tc, nil } -func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error) { - answer := delivery.BuildAnswer{} +func (s AskSteps) build(ctx context.Context, job sdkstep.BuildJob) (any, error) { + answer := sdkstep.BuildAnswer{} tc, err := s.onToolchain(ctx, job.Toolchain) if err != nil { return nil, err @@ -145,7 +172,7 @@ func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error) if err != nil { return nil, err } - if _, candidate := job.Fetch["base"]; candidate { + if job.Fetch.Base != "" { // A candidate is built from its head merged onto the trunk; a head that does not merge leaves the tree at the // trunk, and building that would store the trunk's build under the candidate's fingerprint. if len(got.Conflicts) > 0 { @@ -156,6 +183,24 @@ func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error) } } f = got + if s.Compare != nil { + Phase(ctx, "compare the generated files") + out, err := s.Compare(ctx, map[string]any{"workspace": f.Workspace, "module": job.Path}) + if err != nil { + return nil, fmt.Errorf("the generated files of %s: %w", job.Module, err) + } + r, err := raw(out) + if err != nil { + return nil, err + } + var read struct { + OutOfStep []string `json:"out-of-step"` + } + if err := json.Unmarshal(r, &read); err != nil { + return nil, fmt.Errorf("the compare of %s answered what is not a list: %s", job.Module, r) + } + answer.OutOfStep = read.OutOfStep + } } var ( out any @@ -175,10 +220,21 @@ func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error) if job.Publish { args["publish"] = true } + // The seat it is published through and the version the builder gave it (design 51 *Publishing*, CAT-BG2). + if a.Seat != "" { + args["seat"] = a.Seat + } + if a.Version != "" { + args["version"] = a.Version + } Phase(ctx, "build "+a.Artifact) out, err = s.Build(ctx, args) } if err != nil { + // The compare's out-of-step is answered with the failure, so the build's end still names it. + if len(answer.OutOfStep) > 0 { + return answer, fmt.Errorf("%s: %w", a.Artifact, err) + } return nil, fmt.Errorf("%s: %w", a.Artifact, err) } r, err := raw(out) @@ -192,7 +248,7 @@ func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error) if err := json.Unmarshal(r, &built); err != nil || built.Digest == "" { return nil, fmt.Errorf("the build of %s answered no digest: %s", a.Artifact, r) } - answer.Artifacts = append(answer.Artifacts, delivery.AskBuilt{Artifact: a.Artifact, Digest: built.Digest, Reference: built.Reference}) + answer.Artifacts = append(answer.Artifacts, sdkstep.AskBuilt{Artifact: a.Artifact, Digest: built.Digest, Reference: built.Reference}) } if s.Imports != nil && f.Workspace != "" { out, err := s.Imports(ctx, map[string]any{"workspace": f.Workspace, "module": job.Path}) @@ -209,7 +265,7 @@ func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error) if err := json.Unmarshal(r, &read); err != nil { return nil, fmt.Errorf("the imports of %s answered what is not a list: %s", job.Module, r) } - if err := delivery.CheckImports(read.Imports); err != nil { + if err := trunk.CheckImports(read.Imports); err != nil { return nil, err } answer.Imports = read.Imports @@ -217,16 +273,19 @@ func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error) return answer, nil } -func (s AskSteps) check(ctx context.Context, job delivery.CheckJob) (any, error) { +func (s AskSteps) check(ctx context.Context, job sdkstep.CheckJob) (any, error) { if _, err := s.onToolchain(ctx, job.Toolchain); err != nil { return nil, err } - r, f, err := s.fetch(ctx, job.Fetch) + r, f, err := s.fetch(ctx, &job.Fetch) if err != nil { return nil, err } defer s.release(f.Workspace) - answer := delivery.CheckAnswer{Fetch: r} + answer := sdkstep.CheckAnswer{} + if err := json.Unmarshal(r, &answer.Fetch); err != nil { + return nil, fmt.Errorf("the fetch answered what is not a fetch answer: %s: %w", r, err) + } if len(f.Conflicts) > 0 { return answer, nil // the asker reads the conflict from the fetch's answer: a verdict, not an error } @@ -240,7 +299,7 @@ func (s AskSteps) check(ctx context.Context, job delivery.CheckJob) (any, error) // Every level runs, also after one that did not pass: one check names every failure, not only the first (the operator, // 2026-11-02: "A failed check shows only its first fault"). for _, level := range job.Levels { - la := delivery.LevelAnswer{Level: level} + la := sdkstep.LevelAnswer{Level: level} _, err := s.level(ctx, job, level, f, runner, &la) answer.Levels = append(answer.Levels, la) if err != nil { @@ -251,13 +310,13 @@ func (s AskSteps) check(ctx context.Context, job delivery.CheckJob) (any, error) } // level runs one level of a check ask and says whether it passed. -func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string, f fetched, runner string, la *delivery.LevelAnswer) (bool, error) { +func (s AskSteps) level(ctx context.Context, job sdkstep.CheckJob, level string, f fetched, runner string, la *sdkstep.LevelAnswer) (bool, error) { withRunner := func(args map[string]any) map[string]any { if runner != "" { args["runner"] = runner } if len(job.NodeNames) > 0 { - args[delivery.NodeNamesArg] = job.NodeNames // the names check's (ADR 0567 §12) + args[sdkstep.NodeNamesArg] = job.NodeNames // the names check's (ADR 0567 §12) } return args } @@ -357,7 +416,7 @@ func (s AskSteps) release(workspace string) { // is digested for keeping (ADR 0535 §4); when the installed module answers it (it does not take the runner), the level is // planned as any other and the runner is dropped. The level runs whole and takes nothing when the digest names no package // or does not cover the siblings the fetch placed (ADR 0444, L5). -func (s AskSteps) plan(ctx context.Context, job delivery.CheckJob, level string, f fetched, runner string, withRunner func(map[string]any) map[string]any, la *delivery.LevelAnswer) ([]string, bool, bool) { +func (s AskSteps) plan(ctx context.Context, job sdkstep.CheckJob, level string, f fetched, runner string, withRunner func(map[string]any) map[string]any, la *sdkstep.LevelAnswer) ([]string, bool, bool) { if job.Run != "" { return nil, true, true } @@ -373,7 +432,7 @@ func (s AskSteps) plan(ctx context.Context, job delivery.CheckJob, level string, if err != nil { return nil, true, true } - var d delivery.LevelDigest // packages kept as a sorted list, never keyed by a package path (issue 1930) + var d sdkstep.LevelDigest // packages kept as a sorted list, never keyed by a package path (issue 1930) if err := json.Unmarshal(r, &d); err != nil { return nil, true, true } @@ -404,16 +463,16 @@ func (s AskSteps) plan(ctx context.Context, job delivery.CheckJob, level string, s.Logf("the kept verdict of %s at %s cannot be read, so it runs: %v", p, level, err) } if err == nil && found { - la.Kept = append(la.Kept, delivery.KeptTaken{Package: p, Digest: pd.Digest, Tests: tests}) + la.Kept = append(la.Kept, sdkstep.KeptTaken{Package: p, Digest: pd.Digest, Tests: tests}) continue } } - if needs := d.NeedsOf(p); len(delivery.Lacks(needs, s.Has)) > 0 { + if needs := d.NeedsOf(p); len(sdkstep.Lacks(needs, s.Has)) > 0 { // Its tests would only skip here (issue 890): it is left for a node that has what they need. - la.Unmet = append(la.Unmet, delivery.PackageNeeds{Package: p, Needs: needs}) + la.Unmet = append(la.Unmet, sdkstep.PackageNeeds{Package: p, Needs: needs}) if s.Logf != nil { s.Logf("the %s level of %s is left to a node with %s: this node lacks %s", level, p, - strings.Join(needs, ", "), strings.Join(delivery.Lacks(needs, s.Has), ", ")) + strings.Join(needs, ", "), strings.Join(sdkstep.Lacks(needs, s.Has), ", ")) } continue } @@ -423,18 +482,9 @@ func (s AskSteps) plan(ctx context.Context, job delivery.CheckJob, level string, } // besideOf are the siblings a fetch's arguments place, as a digest echoes them: ` `, sorted. -func besideOf(fetch map[string]any) []string { - raw, err := json.Marshal(fetch["beside"]) - if err != nil { - return nil - } - var refs []struct { - Repository string `json:"repository"` - Commit string `json:"commit"` - } - _ = json.Unmarshal(raw, &refs) +func besideOf(fetch sdkstep.Fetch) []string { var out []string - for _, r := range refs { + for _, r := range fetch.Beside { out = append(out, r.Repository+" "+r.Commit) } sort.Strings(out) diff --git a/modules/mesh-build-go/buildstep/askrun_test.go b/modules/mesh-build-go/buildstep/askrun_test.go index b0fce3978..d9a93e195 100644 --- a/modules/mesh-build-go/buildstep/askrun_test.go +++ b/modules/mesh-build-go/buildstep/askrun_test.go @@ -5,13 +5,14 @@ import ( "encoding/json" "errors" "fmt" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" "slices" "strings" "testing" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + nodebuildgo "git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildgo" + nodebuildnode "git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildnode" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) const runCommit = "0123456789abcdef0123456789abcdef01234567" @@ -22,7 +23,7 @@ type recorder struct { fetch map[string]any tests map[string]bool // package or module directory -> passed digests map[string]string - needs []delivery.PackageNeeds + needs []sdkstep.PackageNeeds } func (r *recorder) steps() AskSteps { @@ -58,19 +59,20 @@ func (r *recorder) steps() AskSteps { } } -func ask(job any) delivery.StepAsk { - a := delivery.StepAsk{ID: sdkstep.NewID(), Kind: delivery.AskCheck, Language: delivery.AskGo, Asker: "t", At: time.Now()} +func ask(job any) sdkstep.StepAsk { + a := sdkstep.StepAsk{ID: sdkstep.NewID(), Kind: sdkstep.AskCheck, Language: sdkstep.AskGo, Asker: "t", At: time.Now()} switch j := job.(type) { - case delivery.BuildJob: + case sdkstep.BuildJob: a.Build = &j - case delivery.CheckJob: + case sdkstep.CheckJob: a.Check = &j } return a } // 1887/A3: a build ask fetches once, builds each artifact from the workspace with publish, copies a pinned one without a -// fetch, and answers each artifact's digest and reference. +// fetch, and answers each artifact's digest and reference; an artifact's seat and the version the builder gave it reach +// its build (CAT-BG2). func TestRunAskBuilds(t *testing.T) { r := &recorder{fetch: map[string]any{"workspace": "/w/1"}} steps := r.steps() @@ -80,25 +82,25 @@ func TestRunAskBuilds(t *testing.T) { steps.Imports = func(ctx context.Context, args map[string]any) (any, error) { b, _ := json.Marshal(args) r.calls = append(r.calls, "imports "+string(b)) - return map[string]any{"imports": []string{"git.novox.be/novox/mesh-sdk/go/announce", "git.novox.be/novox/mesh-sdk/go/delivery"}}, nil + return map[string]any{"imports": []string{"git.novox.be/novox/mesh-sdk/go/announce", "git.novox.be/novox/mesh-sdk/go/health"}}, nil } // Issue 1585: the ask's package versions reach the fetch, and the ask's own fetch arguments stay as they were. - a := ask(delivery.BuildJob{Module: "m", Path: "modules/m", Publish: true, - Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}, - Artifacts: []delivery.AskArtifact{{Artifact: "base", From: "registry/b@sha256:00"}, {Artifact: "bundle"}, {Artifact: "lib"}}}) - a.Packages = delivery.PackageVersions{"git.novox.be/novox/mesh-sdk/go": runCommit} + a := ask(sdkstep.BuildJob{Module: "m", Path: "modules/m", Publish: true, + Fetch: &sdkstep.Fetch{Repository: "novox/x", Commit: runCommit}, + Artifacts: []sdkstep.AskArtifact{{Artifact: "base", From: "registry/b@sha256:00"}, {Artifact: "bundle"}, {Artifact: "lib", Seat: "go-package-registry", Version: "v0.1.15"}}}) + a.Packages = sdkstep.PackageVersions{"git.novox.be/novox/mesh-sdk/go": runCommit} out, err := RunAsk(context.Background(), steps, a) if err != nil { t.Fatal(err) } - if _, put := a.Build.Fetch["packages"]; put { + if len(a.Build.Fetch.Packages) != 0 { t.Error("the ask's fetch arguments were changed") } got := strings.Join(r.calls, "\n") want := `copy {"artifact":"base","from":"registry/b@sha256:00","module":"m"} fetch {"commit":"` + runCommit + `","packages":{"git.novox.be/novox/mesh-sdk/go":"` + runCommit + `"},"repository":"novox/x"} build {"artifact":"bundle","module":"modules/m","publish":true,"workspace":"/w/1"} -build {"artifact":"lib","module":"modules/m","publish":true,"workspace":"/w/1"} +build {"artifact":"lib","module":"modules/m","publish":true,"seat":"go-package-registry","version":"v0.1.15","workspace":"/w/1"} imports {"module":"modules/m","workspace":"/w/1"}` if got != want { t.Fatalf("calls:\n%s\nwant:\n%s", got, want) @@ -106,11 +108,11 @@ imports {"module":"modules/m","workspace":"/w/1"}` if len(released) != 1 || released[0] != "/w/1" { t.Errorf("released %v, want the one workspace", released) } - answer := out.(delivery.BuildAnswer) + answer := out.(sdkstep.BuildAnswer) if len(answer.Artifacts) != 3 || answer.Artifacts[1].Reference != "artifact-store://bundle" { t.Fatalf("answer %+v", answer) } - if !slices.Equal(answer.Imports, []string{"git.novox.be/novox/mesh-sdk/go/announce", "git.novox.be/novox/mesh-sdk/go/delivery"}) { + if !slices.Equal(answer.Imports, []string{"git.novox.be/novox/mesh-sdk/go/announce", "git.novox.be/novox/mesh-sdk/go/health"}) { t.Errorf("the answer's imports are %v", answer.Imports) } } @@ -119,18 +121,19 @@ imports {"module":"modules/m","workspace":"/w/1"}` func TestRunAskRefusesAnotherToolchain(t *testing.T) { r := &recorder{fetch: map[string]any{"workspace": "/w"}} s := r.steps() - s.Toolchain = func(context.Context) (delivery.ToolchainUsed, error) { - return delivery.ToolchainUsed{Version: "go1.27", Recipe: "3"}, nil + s.Toolchain = func(context.Context) (sdkstep.ToolchainUsed, error) { + return sdkstep.ToolchainUsed{Version: "go1.27", Recipe: "3"}, nil } - _, err := RunAsk(context.Background(), s, ask(delivery.BuildJob{Module: "m", Path: ".", Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}, - Artifacts: []delivery.AskArtifact{{Artifact: "bundle"}}, Toolchain: &delivery.ToolchainUsed{Version: "go1.26", Recipe: "3"}})) + _, err := RunAsk(context.Background(), s, ask(sdkstep.BuildJob{Module: "m", Path: ".", Fetch: &sdkstep.Fetch{Repository: "novox/x", Commit: runCommit}, + Artifacts: []sdkstep.AskArtifact{{Artifact: "bundle"}}, Toolchain: &sdkstep.ToolchainUsed{Version: "go1.26", Recipe: "3"}})) var dec *Declining if !errors.As(err, &dec) || len(dec.Lacks) != 1 || len(r.calls) != 0 { t.Fatalf("err %v, calls %v", err, r.calls) } - // A check ask names the toolchain too (ADR 0584), and a node on another declines it before it fetches. - _, err = RunAsk(context.Background(), s, ask(delivery.CheckJob{Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}, Levels: []string{delivery.LevelUnit}, - Toolchain: &delivery.ToolchainUsed{Version: "go1.26", Recipe: "3"}})) + // A check ask names the toolchain too (ADR 0584 clause 2), and a node on another declines it before it fetches. + check := ask(sdkstep.CheckJob{Fetch: sdkstep.Fetch{Repository: "novox/x", Commit: runCommit}, Levels: []string{sdkstep.LevelUnit}, + Toolchain: &sdkstep.ToolchainUsed{Version: "go1.26", Recipe: "3"}}) + _, err = RunAsk(context.Background(), s, check) if !errors.As(err, &dec) || len(r.calls) != 0 { t.Fatalf("a check ask: err %v, calls %v", err, r.calls) } @@ -145,8 +148,8 @@ func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) { digests: map[string]string{"x/pkg/a": "d-a", "x/pkg/b": "d-b", "x/pkg/k": "d-kept"}, tests: map[string]bool{"x/pkg/a": true, "x/pkg/b": false}, } - out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration}, Kept: true, - Runner: "workspace", RunnerWhen: "modules/mesh-build-go/", Fetch: map[string]any{"repository": "novox/x", "commit": runCommit, "base": runCommit}})) + out, err := RunAsk(context.Background(), r.steps(), ask(sdkstep.CheckJob{Levels: []string{sdkstep.LevelUnit, sdkstep.LevelIntegration}, Kept: true, + Runner: "workspace", RunnerWhen: "modules/mesh-build-go/", Fetch: sdkstep.Fetch{Repository: "novox/x", Commit: runCommit, Base: runCommit}})) if err != nil { t.Fatal(err) } @@ -161,20 +164,23 @@ test {"level":"integration","packages":["x/pkg/a"],"workspace":"/w"}` if got != want { t.Fatalf("calls:\n%s\nwant:\n%s", got, want) } - a := out.(delivery.CheckAnswer) + a := out.(sdkstep.CheckAnswer) + if a.Fetch.Workspace != "/w" || !slices.Equal(a.Fetch.Changed, []string{"pkg/b/b_test.go"}) { + t.Fatalf("the answer's fetch is the tool's whole answer, typed: %+v", a.Fetch) + } if len(a.Levels) != 2 { t.Fatalf("levels %+v, want both levels", a.Levels) } u := a.Levels[0] - if u.Level != delivery.LevelUnit || len(u.Kept) != 1 || u.Kept[0].Package != "x/pkg/k" || u.Kept[0].Tests != 7 || len(u.Tests) != 2 || u.Digest == nil { + if u.Level != sdkstep.LevelUnit || len(u.Kept) != 1 || u.Kept[0].Package != "x/pkg/k" || u.Kept[0].Tests != 7 || len(u.Tests) != 2 || u.Digest == nil { t.Fatalf("the unit level %+v", u) } // ADR 0567 §12: a check's node names reach the digest and test tools, for the names check (a kept verdict misses when // they change). r.calls = nil - if _, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, NodeNames: []string{"anchor"}, - Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}})); err != nil || !strings.Contains(strings.Join(r.calls, "\n"), `digest {"level":"unit","node-names":["anchor"]`) || + if _, err := RunAsk(context.Background(), r.steps(), ask(sdkstep.CheckJob{Levels: []string{sdkstep.LevelUnit}, NodeNames: []string{"anchor"}, + Fetch: sdkstep.Fetch{Repository: "novox/x", Commit: runCommit}})); err != nil || !strings.Contains(strings.Join(r.calls, "\n"), `digest {"level":"unit","node-names":["anchor"]`) || !strings.Contains(strings.Join(r.calls, "\n"), `test {"level":"unit","node-names":["anchor"]`) { t.Fatalf("the node names are not handed to the digest and test tools: %v, %v", r.calls, err) } @@ -183,19 +189,19 @@ test {"level":"integration","packages":["x/pkg/a"],"workspace":"/w"}` // tested on a node that has it. for _, has := range []bool{false, true} { r.calls, r.tests["x/pkg/b"] = nil, true - r.needs = []delivery.PackageNeeds{{Package: "x/pkg/a", Needs: []string{delivery.NeedNodeBuildNode}}} + r.needs = []sdkstep.PackageNeeds{{Package: "x/pkg/a", Needs: []string{sdkstep.NeedNodeBuildNode}}} steps := r.steps() if has { - steps.Has = func(n string) bool { return n == delivery.NeedNodeBuildNode } + steps.Has = func(n string) bool { return n == sdkstep.NeedNodeBuildNode } } - out, err := RunAsk(context.Background(), steps, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Kept: true, - Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}})) + out, err := RunAsk(context.Background(), steps, ask(sdkstep.CheckJob{Levels: []string{sdkstep.LevelUnit}, Kept: true, + Fetch: sdkstep.Fetch{Repository: "novox/x", Commit: runCommit}})) if err != nil { t.Fatal(err) } - u := out.(delivery.CheckAnswer).Levels[0] + u := out.(sdkstep.CheckAnswer).Levels[0] tested := strings.Contains(strings.Join(r.calls, "\n"), `"x/pkg/a"`) - if tested != has || (len(u.Unmet) == 0) == !has || (!has && (u.Unmet[0].Package != "x/pkg/a" || delivery.UnmetNeeds([]delivery.LevelAnswer{u})[0] != delivery.NeedNodeBuildNode)) { + if tested != has || (len(u.Unmet) == 0) == !has || (!has && (u.Unmet[0].Package != "x/pkg/a" || sdkstep.UnmetNeeds([]sdkstep.LevelAnswer{u})[0] != sdkstep.NeedNodeBuildNode)) { t.Fatalf("has %v: tested x/pkg/a %v, unmet %+v, calls %v", has, tested, u.Unmet, r.calls) } } @@ -205,12 +211,12 @@ test {"level":"integration","packages":["x/pkg/a"],"workspace":"/w"}` r.calls, r.digests = nil, digests steps := r.steps() steps.Has = func(string) bool { return true } - out, err := RunAsk(context.Background(), steps, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Kept: true, Packages: []string{"x/pkg/a"}, - Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}})) + out, err := RunAsk(context.Background(), steps, ask(sdkstep.CheckJob{Levels: []string{sdkstep.LevelUnit}, Kept: true, Packages: []string{"x/pkg/a"}, + Fetch: sdkstep.Fetch{Repository: "novox/x", Commit: runCommit}})) if err != nil { t.Fatal(err) } - u := out.(delivery.CheckAnswer).Levels[0] + u := out.(sdkstep.CheckAnswer).Levels[0] if last := r.calls[len(r.calls)-1]; !strings.HasSuffix(last, `"packages":["x/pkg/a"],"workspace":"/w"}`) || len(u.Kept) != 0 || len(u.Tests) != 1 { t.Fatalf("digests %v: calls %v, kept %+v: want x/pkg/a alone tested, nothing taken kept", digests != nil, r.calls, u.Kept) } @@ -234,15 +240,15 @@ test {"level":"unit","runner":"workspace","workspace":"/w"}`}, _, _ = digest(ctx, args) return map[string]any{"level": "unit", "packages": r.digests, "judged_by": tc.judged}, nil } - out, err := RunAsk(context.Background(), steps, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Kept: true, - Runner: "workspace", RunnerWhen: "modules/mesh-build-go/", Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}})) + out, err := RunAsk(context.Background(), steps, ask(sdkstep.CheckJob{Levels: []string{sdkstep.LevelUnit}, Kept: true, + Runner: "workspace", RunnerWhen: "modules/mesh-build-go/", Fetch: sdkstep.Fetch{Repository: "novox/x", Commit: runCommit}})) if err != nil { t.Fatal(err) } if got := strings.Join(r.calls[1:], "\n"); got != tc.want { t.Errorf("judged by %s: calls\n%s\nwant\n%s", tc.judged, got, tc.want) } - l := out.(delivery.CheckAnswer).Levels[0] + l := out.(sdkstep.CheckAnswer).Levels[0] if tc.judged == "installed" && (len(l.Kept) != 1 || l.Judged != "") { t.Errorf("an installed module's level %+v, want the kept verdict taken", l) } @@ -251,12 +257,12 @@ test {"level":"unit","runner":"workspace","workspace":"/w"}`}, } } r := &recorder{fetch: map[string]any{"workspace": "/w"}, digests: map[string]string{"x/pkg/a": "d-kept"}, tests: map[string]bool{}} - out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Kept: true, - Fetch: map[string]any{"repository": "novox/x", "commit": runCommit, "beside": []any{map[string]any{"repository": "novox/mesh-sdk", "commit": runCommit}}}})) + out, err := RunAsk(context.Background(), r.steps(), ask(sdkstep.CheckJob{Levels: []string{sdkstep.LevelUnit}, Kept: true, + Fetch: sdkstep.Fetch{Repository: "novox/x", Commit: runCommit, Beside: []sdkstep.FetchBeside{{Repository: "novox/mesh-sdk", Commit: runCommit}}}})) if err != nil { t.Fatal(err) } - if l := out.(delivery.CheckAnswer).Levels[0]; len(l.Kept) != 0 || l.Digest != nil { + if l := out.(sdkstep.CheckAnswer).Levels[0]; len(l.Kept) != 0 || l.Digest != nil { t.Fatalf("a digest without the siblings kept %+v", l) } } @@ -268,15 +274,15 @@ func TestRunAskChecksNodeModulesAndConflicts(t *testing.T) { tests: map[string]bool{"a": true, "b": true}} s := r.steps() s.Digest = nil - if _, err := RunAsk(context.Background(), s, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}})); err != nil { + if _, err := RunAsk(context.Background(), s, ask(sdkstep.CheckJob{Levels: []string{sdkstep.LevelUnit}, Fetch: sdkstep.Fetch{Repository: "novox/x", Commit: runCommit}})); err != nil { t.Fatal(err) } if fmt.Sprint(r.calls[1:]) != `[test {"level":"unit","module":"b","workspace":"/w"} test {"level":"unit","module":"a","workspace":"/w"}]` { t.Fatalf("calls %v", r.calls) } r = &recorder{fetch: map[string]any{"workspace": "/w", "conflicts": []string{"go.mod"}}} - out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}})) - if err != nil || len(r.calls) != 1 || len(out.(delivery.CheckAnswer).Levels) != 0 { + out, err := RunAsk(context.Background(), r.steps(), ask(sdkstep.CheckJob{Levels: []string{sdkstep.LevelUnit}, Fetch: sdkstep.Fetch{Repository: "novox/x", Commit: runCommit}})) + if err != nil || len(r.calls) != 1 || len(out.(sdkstep.CheckAnswer).Levels) != 0 { t.Fatalf("a conflict: %v %v", err, r.calls) } } @@ -300,8 +306,8 @@ func TestRunAskRunsEveryPackageAndLevelAfterAFailure(t *testing.T) { if _, ok := row.tests["x/pkg/c"]; ok { r.digests["x/pkg/c"] = "d-c" } - out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration}, - Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}})) + out, err := RunAsk(context.Background(), r.steps(), ask(sdkstep.CheckJob{Levels: []string{sdkstep.LevelUnit, sdkstep.LevelIntegration}, + Fetch: sdkstep.Fetch{Repository: "novox/x", Commit: runCommit}})) if err != nil { t.Fatal(err) } @@ -316,7 +322,7 @@ func TestRunAskRunsEveryPackageAndLevelAfterAFailure(t *testing.T) { if want := append(slices.Clone(row.want), row.want...); fmt.Sprint(got) != fmt.Sprint(want) { t.Errorf("%s: tested %v, want %v", row.name, got, want) } - if a := out.(delivery.CheckAnswer); len(a.Levels) != 2 || len(a.Levels[0].Tests) != len(row.want) { + if a := out.(sdkstep.CheckAnswer); len(a.Levels) != 2 || len(a.Levels[0].Tests) != len(row.want) { t.Errorf("%s: levels %+v, want both, each with every test result", row.name, a.Levels) } } @@ -325,12 +331,41 @@ func TestRunAskRunsEveryPackageAndLevelAfterAFailure(t *testing.T) { tests: map[string]bool{"a": true, "b": false}} s := r.steps() s.Digest = nil - out, err := RunAsk(context.Background(), s, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration}, - Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}})) + out, err := RunAsk(context.Background(), s, ask(sdkstep.CheckJob{Levels: []string{sdkstep.LevelUnit, sdkstep.LevelIntegration}, + Fetch: sdkstep.Fetch{Repository: "novox/x", Commit: runCommit}})) if err != nil { t.Fatal(err) } - if a := out.(delivery.CheckAnswer); len(r.calls) != 5 || len(a.Levels) != 2 || len(a.Levels[0].Tests) != 2 { + if a := out.(sdkstep.CheckAnswer); len(r.calls) != 5 || len(a.Levels) != 2 || len(a.Levels[0].Tests) != 2 { t.Fatalf("calls %v, levels %+v: want b then a at each of the two levels", r.calls, a.Levels) } } + +// A seat's own ask type (the generated one of any of the three build seats) is the neutral ask through the form's one +// conversion (sdkstep.AskOf), reads for its step description through the same conversion (askRead), and reaches the +// fetch tool as the fetch's arguments alone: no layout version, nothing of the ask's. +func TestASeatsAskTypeRunsAsTheNeutralAsk(t *testing.T) { + at := time.Now().UTC() + fetch := &nodebuildgo.Fetch{Commit: runCommit, Repository: "novox/x", Dir: "modules/m", Paths: []string{"src"}} + goAsk := nodebuildgo.StepAsk{V: sdkstep.StepAskVersion, ID: sdkstep.NewID(), Kind: sdkstep.AskDelivery, Language: sdkstep.AskGo, Asker: "mesh-delivery-builder", At: &at, + Build: &nodebuildgo.StepAskBuild{Module: "m", Path: "modules/m", Fetch: fetch, Artifacts: []nodebuildgo.AskArtifact{{Artifact: "bundle"}}}, + Packages: map[string]string{"git.novox.be/novox/mesh-sdk/go": runCommit}} + a, err := sdkstep.AskOf(goAsk) + if err != nil { + t.Fatal(err) + } + if read := askRead(a); read.Kind != sdkstep.AskDelivery || read.Build == nil || read.Build.Module != "m" { + t.Errorf("the step description reads %+v", read) + } + r := &recorder{fetch: map[string]any{"workspace": "/w"}} + if _, err := RunAsk(context.Background(), r.steps(), a); err != nil { + t.Fatal(err) + } + want := `fetch {"commit":"` + runCommit + `","dir":"modules/m","packages":{"git.novox.be/novox/mesh-sdk/go":"` + runCommit + `"},"paths":["src"],"repository":"novox/x"}` + if len(r.calls) == 0 || r.calls[0] != want { + t.Errorf("the fetch tool was called with %v, want %s", r.calls, want) + } + if back, err := sdkstep.AskAs[nodebuildnode.StepAsk](a); err != nil || back.Build == nil || back.Build.Module != "m" { + t.Errorf("the ask as another seat's type: %+v, %v", back, err) + } +} diff --git a/modules/mesh-build-go/buildstep/blob.go b/modules/mesh-build-go/buildstep/blob.go deleted file mode 100644 index 127c6ace2..000000000 --- a/modules/mesh-build-go/buildstep/blob.go +++ /dev/null @@ -1,170 +0,0 @@ -package buildstep - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "syscall" - "time" -) - -// RegistryWait is how long a push waits for an artifact store that refuses connections, longer than its nightly -// collection holds it still (about a minute and a half, issue 457); RegistryFirstPause is the first pause, doubling -// to RegistryMostPause. Variables so a test shortens them. -var ( - RegistryWait = 5 * time.Minute - RegistryFirstPause = time.Second - RegistryMostPause = 10 * time.Second -) - -// BlobReference is where a pushed bundle or archive is, as a done key names it and the controller delivers from -// (novox/hq ADR 0498 clause 1): artifact-store:////blobs/, the store's address left out -// (issue 102). -func BlobReference(module, artifact, digest string) string { - return "artifact-store://" + module + "/" + artifact + "/blobs/" + digest -} - -// PushBlob puts body into the artifact store at registry (host:port) as a blob of repository (/) by -// its digest (`sha256:` and hex), from the build module's own process, after the build step ended (novox/hq ADR 0498 -// clauses 1 and 2): a blob the store already holds is done without an upload; otherwise it is uploaded (POST -// blobs/uploads/, then PUT with the digest). A store refusing connections is waited out up to RegistryWait; any other -// refusal fails, naming the store's status and its own words, for the caller's `not stored:` error. Bytes that are not -// their digest are refused before anything is sent. -func PushBlob(ctx context.Context, client *http.Client, registry, repository, digest string, body []byte) error { - sum := sha256.Sum256(body) - if got := "sha256:" + hex.EncodeToString(sum[:]); got != digest { - return fmt.Errorf("the blob is %s, not the digest %s it was to be pushed as", got, digest) - } - if client == nil { - client = http.DefaultClient - } - base := "http://" + registry + "/v2/" + repository + "/blobs/" - var present bool - if err := waitForStore(ctx, registry, func() error { - var err error - present, err = blobPresent(ctx, client, base+digest) - return err - }); err != nil { - return err - } - if present { - return nil - } - var location string - if err := waitForStore(ctx, registry, func() error { - var err error - location, err = startUpload(ctx, client, base+"uploads/") - return err - }); err != nil { - return err - } - target, err := url.Parse(base) - if err != nil { - return err - } - loc, err := target.Parse(location) - if err != nil { - return fmt.Errorf("the store answered the upload's location %q, which is not a URL: %w", location, err) - } - q := loc.Query() - q.Set("digest", digest) - loc.RawQuery = q.Encode() - req, err := http.NewRequestWithContext(ctx, http.MethodPut, loc.String(), bytes.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/octet-stream") - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("putting %s of %s: %w", digest, repository, err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusCreated { - return storeSaid(resp, "putting "+digest+" of "+repository) - } - return nil -} - -func blobPresent(ctx context.Context, client *http.Client, at string) (bool, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodHead, at, nil) - if err != nil { - return false, err - } - resp, err := client.Do(req) - if err != nil { - return false, err - } - resp.Body.Close() - switch resp.StatusCode { - case http.StatusOK: - return true, nil - case http.StatusNotFound: - return false, nil - } - return false, fmt.Errorf("asking the store for %s: it answered %s", at, resp.Status) -} - -func startUpload(ctx context.Context, client *http.Client, at string) (string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, at, nil) - if err != nil { - return "", err - } - resp, err := client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusAccepted { - return "", storeSaid(resp, "starting an upload at "+at) - } - location := resp.Header.Get("Location") - if location == "" { - return "", fmt.Errorf("starting an upload at %s: the store named no location", at) - } - return location, nil -} - -// storeSaid is the store's refusal: its status and the first of its own words. -func storeSaid(resp *http.Response, what string) error { - words, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) - return fmt.Errorf("%s: the store answered %s: %s", what, resp.Status, strings.TrimSpace(string(words))) -} - -// refused is whether an error is a connection refused. -func refused(err error) bool { - return err != nil && (errors.Is(err, syscall.ECONNREFUSED) || strings.Contains(err.Error(), "connection refused")) -} - -// waitForStore runs try, and while it fails because the store refuses connections, tries again with a growing pause -// until RegistryWait has passed. A refusal is the one error waited for: nothing was sent, so trying again does nothing -// twice. -func waitForStore(ctx context.Context, registry string, try func() error) error { - err := try() - if !refused(err) { - return err - } - started, pause := time.Now(), RegistryFirstPause - for { - left := RegistryWait - time.Since(started) - if left <= 0 { - return fmt.Errorf("the artifact store at %s refused every connection for %s, longer than its nightly collection holds it still, so it is down, not paused: %w", - registry, time.Since(started).Round(time.Millisecond), err) - } - select { - case <-ctx.Done(): - return fmt.Errorf("stopped while waiting for the artifact store at %s: %w (last: %v)", registry, ctx.Err(), err) - case <-time.After(min(pause, left)): - } - pause = min(pause*2, RegistryMostPause) - if err = try(); !refused(err) { - return err - } - } -} diff --git a/modules/mesh-build-go/buildstep/blob_test.go b/modules/mesh-build-go/buildstep/blob_test.go deleted file mode 100644 index b77afb0c1..000000000 --- a/modules/mesh-build-go/buildstep/blob_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package buildstep - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "io" - "net" - "net/http" - "net/http/httptest" - "strings" - "sync" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -func digestOfBytes(b []byte) string { - h := sha256.Sum256(b) - return "sha256:" + hex.EncodeToString(h[:]) -} - -// registry is an artifact store as far as a blob push needs one: HEAD a blob, start an upload, PUT it with its digest. -type registry struct { - mu sync.Mutex - blobs map[string][]byte - calls []string - put int // the status a PUT answers, 201 when 0 -} - -func (r *registry) ServeHTTP(w http.ResponseWriter, req *http.Request) { - r.mu.Lock() - defer r.mu.Unlock() - r.calls = append(r.calls, req.Method+" "+req.URL.Path) - switch { - case req.Method == http.MethodHead && strings.Contains(req.URL.Path, "/blobs/sha256:"): - if _, ok := r.blobs[req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:]]; ok { - w.WriteHeader(http.StatusOK) - return - } - w.WriteHeader(http.StatusNotFound) - case req.Method == http.MethodPost && strings.HasSuffix(req.URL.Path, "/blobs/uploads/"): - w.Header().Set("Location", strings.TrimSuffix(req.URL.Path, "/")+"/u1?state=x") - w.WriteHeader(http.StatusAccepted) - case req.Method == http.MethodPut && strings.Contains(req.URL.Path, "/blobs/uploads/u1"): - if r.put != 0 { - w.WriteHeader(r.put) - _, _ = io.WriteString(w, `{"errors":[{"code":"DIGEST_INVALID","message":"provided digest did not match uploaded content"}]}`) - return - } - body, _ := io.ReadAll(req.Body) - d := req.URL.Query().Get("digest") - if req.URL.Query().Get("state") != "x" || d != digestOfBytes(body) { - w.WriteHeader(http.StatusBadRequest) - return - } - r.blobs[d] = body - w.WriteHeader(http.StatusCreated) - default: - w.WriteHeader(http.StatusMethodNotAllowed) - } -} - -// A done build's bundle or archive is pushed to the artifact store by its build module (novox/hq ADR 0498 clauses 1 -// and 2): a blob the store already holds is done with no upload; one it lacks is uploaded by digest; its reference is -// the artifact-store form the builder's key and the controller check. -func TestABlobIsPushedOnceByItsDigest(t *testing.T) { - reg := ®istry{blobs: map[string][]byte{}} - srv := httptest.NewServer(reg) - defer srv.Close() - addr := strings.TrimPrefix(srv.URL, "http://") - body := []byte("the packed bundle") - d := digestOfBytes(body) - if err := PushBlob(context.Background(), srv.Client(), addr, "mail/code", d, body); err != nil { - t.Fatal(err) - } - if string(reg.blobs[d]) != string(body) { - t.Fatalf("the store holds %q", reg.blobs[d]) - } - reg.calls = nil - if err := PushBlob(context.Background(), srv.Client(), addr, "mail/code", d, body); err != nil { - t.Fatal(err) - } - if len(reg.calls) != 1 || !strings.HasPrefix(reg.calls[0], "HEAD ") { - t.Fatalf("a blob the store holds was uploaded again: %v", reg.calls) - } - ref := BlobReference("mail", "code", d) - if ref != "artifact-store://mail/code/blobs/"+d { - t.Fatalf("BlobReference = %q", ref) - } - b := delivery.BuilderBuild{State: delivery.BuildDone, Manifest: strings.Repeat("4", 64), - Artifacts: []delivery.BuiltArtifact{{Name: "code", Kind: "bundle", Digest: d, Reference: ref}}} - if _, err := delivery.EncodeBuilderBuild(b); err != nil { - t.Fatalf("the key refuses the reference BlobReference gives: %v", err) - } -} - -// A store that refuses the upload fails the push naming the store's own words; bytes that are not the digest are -// refused before anything is sent. -func TestAPushTheStoreRefusesSaysTheStoresWords(t *testing.T) { - reg := ®istry{blobs: map[string][]byte{}, put: http.StatusBadRequest} - srv := httptest.NewServer(reg) - defer srv.Close() - addr := strings.TrimPrefix(srv.URL, "http://") - body := []byte("x") - err := PushBlob(context.Background(), srv.Client(), addr, "mail/code", digestOfBytes(body), body) - if err == nil || !strings.Contains(err.Error(), "provided digest did not match") || !strings.Contains(err.Error(), "400") { - t.Fatalf("a refused upload said %v", err) - } - reg.calls = nil - err = PushBlob(context.Background(), srv.Client(), addr, "mail/code", digestOfBytes([]byte("other")), body) - if err == nil || len(reg.calls) != 0 { - t.Fatalf("bytes that are not their digest: %v, calls %v", err, reg.calls) - } -} - -// A store refusing connections (its nightly collection holds it still) is waited out, up to RegistryWait (issue 457). -func TestAPushWaitsOutAStoreRefusingConnections(t *testing.T) { - l, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - addr := l.Addr().String() - l.Close() // refusing now - reg := ®istry{blobs: map[string][]byte{}} - go func() { - time.Sleep(300 * time.Millisecond) - l2, err := net.Listen("tcp", addr) - if err != nil { - return - } - _ = http.Serve(l2, reg) - }() - beforeWait, beforePause := RegistryWait, RegistryFirstPause - RegistryWait, RegistryFirstPause = 3*time.Second, 100*time.Millisecond - defer func() { RegistryWait, RegistryFirstPause = beforeWait, beforePause }() - body := []byte("late") - if err := PushBlob(context.Background(), http.DefaultClient, addr, "mail/code", digestOfBytes(body), body); err != nil { - t.Fatalf("a store that came back was not waited for: %v", err) - } - - l3, _ := net.Listen("tcp", "127.0.0.1:0") - gone := l3.Addr().String() - l3.Close() - RegistryWait = 300 * time.Millisecond - err = PushBlob(context.Background(), http.DefaultClient, gone, "mail/code", digestOfBytes(body), body) - if err == nil || !strings.Contains(err.Error(), "refused every connection") { - t.Fatalf("a store that stayed down said %v", err) - } -} diff --git a/modules/mesh-build-go/buildstep/capacity.go b/modules/mesh-build-go/buildstep/capacity.go index d545fb305..14fa874d0 100644 --- a/modules/mesh-build-go/buildstep/capacity.go +++ b/modules/mesh-build-go/buildstep/capacity.go @@ -1,7 +1,7 @@ package buildstep import ( - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "encoding/json" "fmt" diff --git a/modules/mesh-build-go/buildstep/capacity_cpu.go b/modules/mesh-build-go/buildstep/capacity_cpu.go index f8f125403..ff9e3c829 100644 --- a/modules/mesh-build-go/buildstep/capacity_cpu.go +++ b/modules/mesh-build-go/buildstep/capacity_cpu.go @@ -10,7 +10,7 @@ import ( "strings" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/buildcpu" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // **A build seat's capacity follows the CPU its node gives builds** (issue 1806's follow-up; issue 1890, 2026-10-26 @@ -30,7 +30,7 @@ const CoresPerStepSetting = "cores-per-step" // check on mesh-build-go takes what it is given (three at once took 965 % of the workstation's 1200 % slice, about 4 cores each, // and ran past the 30-minute step bound), so a Go step is given 6, half again what starved; a Node step (tsc, esbuild, a // test runner: median 15 s) and an image build (buildah, its RUN lines mostly fetches) need 2. -var DefaultCoresPerStep = map[string]float64{delivery.AskGo: 6, delivery.AskNode: 2, delivery.AskImage: 2} +var DefaultCoresPerStep = map[string]float64{sdkstep.AskGo: 6, sdkstep.AskNode: 2, sdkstep.AskImage: 2} // Quota is the build slice's CPU quota as this process's cgroup holds it, in percent of one processor (1200 is 12 // cores), and where it was read: the cgroup's cpu.max (buildcpu.ReadInForce, the tighter of its unit's and its slice's), @@ -54,11 +54,11 @@ func Quota() (percent int, from string) { // one processor, 0 when none is known) and the cores one of its steps needs. Without a quota it is the configured one; // with one, min(configured, floor(quota cores / coresPerStep)), and one at least while the configured one is above // zero. A coresPerStep of zero or less is taken as 1. -func EffectiveCapacity(configured, quotaPercent int, quotaFrom string, coresPerStep float64) delivery.Capacity { +func EffectiveCapacity(configured, quotaPercent int, quotaFrom string, coresPerStep float64) sdkstep.Capacity { if coresPerStep <= 0 || math.IsNaN(coresPerStep) || math.IsInf(coresPerStep, 0) { coresPerStep = 1 } - c := delivery.Capacity{Configured: configured, Effective: configured, QuotaFrom: quotaFrom, CoresPerStep: coresPerStep} + c := sdkstep.Capacity{Configured: configured, Effective: configured, QuotaFrom: quotaFrom, CoresPerStep: coresPerStep} switch { case configured <= 0: c.Effective = 0 diff --git a/modules/mesh-build-go/buildstep/cmd/seatgen/main.go b/modules/mesh-build-go/buildstep/cmd/seatgen/main.go new file mode 100644 index 000000000..f550b911e --- /dev/null +++ b/modules/mesh-build-go/buildstep/cmd/seatgen/main.go @@ -0,0 +1,80 @@ +// Command seatgen writes, or with -check compares, a contracts module's generated Go and TypeScript. +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "sort" + + "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/seatgen" +) + +func main() { + check := flag.Bool("check", false, "compare instead of writing: name every path out of step") + goModule := flag.String("go-module", "", "the generated Go tree's module path, such as git.novox.be/novox/mesh-contracts//go (there is no root module)") + tsPackage := flag.String("ts-package", "", "the generated TypeScript package's name, such as @novox/mesh-contracts-") + domainSDK := flag.String("domain-sdk", "", "the Go module of the contracts module's domain SDK, which a client may import (ADR 0634 §2); empty when it has none") + flag.Parse() + dir := flag.Arg(0) + m, err := seatgen.Load(dir, *goModule, *tsPackage) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + m.DomainSDK = *domainSDK + files, err := seatgen.Generate(m) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if *check { + out, err := seatgen.Compare(dir, files) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + // The clients beside the contracts (ADR 0627 §3) and module.json's Go and npm package artifacts (ADR 0625 §1, §3). + for _, check := range []func(string, *seatgen.Module) ([]string, error){seatgen.CheckClients, seatgen.CheckArtifacts} { + more, err := check(dir, m) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + out = append(out, more...) + } + for _, p := range out { + fmt.Println(p) + } + if len(out) > 0 { + os.Exit(1) + } + return + } + for _, root := range seatgen.GeneratedRoots { + _ = os.RemoveAll(filepath.Join(dir, root, "src")) + } + // Each seat's npm package's sources too (ADR 0625 §1), so a file no definition writes any more is gone. + stale, _ := filepath.Glob(filepath.Join(dir, "ts", "seats", "*", "src")) + for _, p := range stale { + _ = os.RemoveAll(p) + } + paths := make([]string, 0, len(files)) + for p := range files { + paths = append(paths, p) + } + sort.Strings(paths) + for _, p := range paths { + full := filepath.Join(dir, p) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if err := os.WriteFile(full, files[p], 0o644); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + } + fmt.Println(len(paths), "files") +} diff --git a/modules/mesh-build-go/buildstep/conformance/conformance.go b/modules/mesh-build-go/buildstep/conformance/conformance.go index 78596ff26..97de1fef5 100644 --- a/modules/mesh-build-go/buildstep/conformance/conformance.go +++ b/modules/mesh-build-go/buildstep/conformance/conformance.go @@ -15,7 +15,7 @@ import ( "strings" "testing" - "git.novox.be/novox/mesh-sdk/go/buildstep" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // The runners the kit holds output of. diff --git a/modules/mesh-build-go/buildstep/conformance/conformance_test.go b/modules/mesh-build-go/buildstep/conformance/conformance_test.go index 0e1745d91..b1460c86d 100644 --- a/modules/mesh-build-go/buildstep/conformance/conformance_test.go +++ b/modules/mesh-build-go/buildstep/conformance/conformance_test.go @@ -8,7 +8,8 @@ import ( "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" + buildrun "git.novox.be/novox/mesh-delivery/sdk/buildrun" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // The failure contract (issue 1901): the SDK's own parsers keep it on every captured case of the kit (1901/AC6); the @@ -25,14 +26,14 @@ func TestTheFailureContract(t *testing.T) { // 1890/AC7: the SDK's start sweep keeps the left-running case, and the kit refuses a sweep that ends nothing or ends // everything. t.Run("the left-running case", func(t *testing.T) { - LeftRunning(t, func(ctx context.Context, scan sdkstep.Scan, node string, before time.Time, logf func(string, ...any)) { - sdkstep.EndStaleAtStart(ctx, scan, node, before, logf) + LeftRunning(t, func(ctx context.Context, scan buildrun.Scan, node string, before time.Time, logf func(string, ...any)) { + buildrun.EndStaleAtStart(ctx, scan, node, before, logf) }) - if p := JudgeLeftRunning(func(context.Context, sdkstep.Scan, string, time.Time, func(string, ...any)) {}); len(p) != 11 { + if p := JudgeLeftRunning(func(context.Context, buildrun.Scan, string, time.Time, func(string, ...any)) {}); len(p) != 11 { t.Errorf("a sweep that ends nothing: %d problems, want 11 (three steps not ended and unsaid, one owner unnamed, two asks not deleted and unsaid):\n%s", len(p), strings.Join(p, "\n")) } - wrongNode := func(ctx context.Context, scan sdkstep.Scan, _ string, before time.Time, logf func(string, ...any)) { - sdkstep.EndStaleAtStart(ctx, scan, "kitother", before, logf) + wrongNode := func(ctx context.Context, scan buildrun.Scan, _ string, before time.Time, logf func(string, ...any)) { + buildrun.EndStaleAtStart(ctx, scan, "kitother", before, logf) } if p := JudgeLeftRunning(wrongNode); len(p) == 0 { t.Error("a sweep of another node's steps is not refused") diff --git a/modules/mesh-build-go/buildstep/conformance/leftrunning.go b/modules/mesh-build-go/buildstep/conformance/leftrunning.go index a8812c4d2..5e5bfa303 100644 --- a/modules/mesh-build-go/buildstep/conformance/leftrunning.go +++ b/modules/mesh-build-go/buildstep/conformance/leftrunning.go @@ -10,9 +10,9 @@ import ( "testing" "time" + buildrun "git.novox.be/novox/mesh-delivery/sdk/buildrun" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" ) // **A step left running by a previous process** (novox/hq issue 1890, note 21): when a delivery replaced mesh-build-go @@ -23,9 +23,9 @@ import ( // catalogue check (mesh-catalog checks/failurekit) refuses a build module whose tests do not call LeftRunning. // Sweep is a build module's own start sweep, as its main runs it once at its start: it ends node's steps in scan that -// no process of the module runs any more (buildstep.EndStaleAtStart, here with the runtime already serving), before +// no process of the module runs any more (buildrun.EndStaleAtStart, here with the runtime already serving), before // being the start of the process, saying each end with logf. -type Sweep func(ctx context.Context, scan buildstep.Scan, node string, before time.Time, logf func(string, ...any)) +type Sweep func(ctx context.Context, scan buildrun.Scan, node string, before time.Time, logf func(string, ...any)) // LeftNode is the node the case's steps are of. const LeftNode = "kitnode" @@ -47,12 +47,12 @@ func leftCases() []leftCase { {id: "mvpwlkzi7e6ca8", tool: "check ask", owner: gone, age: 5 * time.Minute, deleted: true}, // note 21's ghost {id: "mvpwlkzi7e6ca9", tool: "delivery ask", owner: gone, age: -time.Second, deleted: true}, {id: "gonehand1", tool: "build_x_test", owner: gone, age: time.Minute, ended: true}, - {id: "oldhand1", tool: "build_x_fetch", age: time.Hour, ended: true}, // no owner: an older build's - {id: "mvpwlkzi7e6caa", tool: "check ask", age: time.Hour, ended: true}, // no owner, past the bound - {id: "mvpwlkzi7e6cab", tool: "check ask", age: 5 * time.Minute}, // no owner, the stream's still - {id: "mvpwlkzi7e6cac", tool: "check ask", owner: buildstep.Owner, age: time.Minute}, // this process's - {id: "minehand1", tool: "build_x_test", owner: buildstep.Owner, age: -time.Second}, - {id: "donehand1", tool: "build_x_test", owner: gone, age: time.Hour, state: delivery.StepPassed}, + {id: "oldhand1", tool: "build_x_fetch", age: time.Hour, ended: true}, // no owner: an older build's + {id: "mvpwlkzi7e6caa", tool: "check ask", age: time.Hour, ended: true}, // no owner, past the bound + {id: "mvpwlkzi7e6cab", tool: "check ask", age: 5 * time.Minute}, // no owner, the stream's still + {id: "mvpwlkzi7e6cac", tool: "check ask", owner: buildrun.Owner, age: time.Minute}, // this process's + {id: "minehand1", tool: "build_x_test", owner: buildrun.Owner, age: -time.Second}, + {id: "donehand1", tool: "build_x_test", owner: gone, age: time.Hour, state: sdkstep.StepPassed}, {id: "otherhand1", tool: "build_x_test", owner: gone, age: time.Hour, node: "kitother"}, } } @@ -76,10 +76,10 @@ func JudgeLeftRunning(sweep Sweep) []string { node = LeftNode } if state == "" { - state = delivery.StepRunning + state = sdkstep.StepRunning } - key, _ := delivery.StepKey(node, c.id) - raw, err := delivery.EncodeStep(delivery.Step{ID: c.id, Tool: c.tool, Node: node, State: state, Started: before.Add(-c.age).UTC(), Owner: c.owner}) + key, _ := sdkstep.StepKey(node, c.id) + raw, err := sdkstep.EncodeStep(sdkstep.Step{ID: c.id, Tool: c.tool, Node: node, State: state, Started: before.Add(-c.age).UTC(), Owner: c.owner}) if err != nil { return []string{"the kit's step " + c.id + ": " + err.Error()} } @@ -87,9 +87,9 @@ func JudgeLeftRunning(sweep Sweep) []string { } served := make(chan struct{}) close(served) - was := buildstep.StaleServed - buildstep.StaleServed = func() <-chan struct{} { return served } - defer func() { buildstep.StaleServed = was }() + was := buildrun.StaleServed + buildrun.StaleServed = func() <-chan struct{} { return served } + defer func() { buildrun.StaleServed = was }() var mu sync.Mutex var said []string ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -108,7 +108,7 @@ func JudgeLeftRunning(sweep Sweep) []string { if node == "" { node = LeftNode } - key, _ := delivery.StepKey(node, c.id) + key, _ := sdkstep.StepKey(node, c.id) raw := st.get(key) if c.deleted { if raw != nil { @@ -124,17 +124,17 @@ func JudgeLeftRunning(sweep Sweep) []string { problems = append(problems, "left running: the step "+key+" is gone after the sweep, want it kept or ended") continue } - s, err := delivery.DecodeStep(raw) + s, err := sdkstep.DecodeStep(raw) if err != nil { problems = append(problems, key+": "+err.Error()) continue } want := c.state if want == "" { - want = delivery.StepRunning + want = sdkstep.StepRunning } if c.ended { - want = delivery.StepFailed + want = sdkstep.StepFailed } if s.State != want { problems = append(problems, fmt.Sprintf("left running: the step %s (%s, owner %q, %s before the start) is %s after the sweep, want %s", key, c.tool, c.owner, c.age, s.State, want)) @@ -169,8 +169,8 @@ func (s *steps) get(key string) []byte { return s.vals[key] } -func (s *steps) scan() buildstep.Scan { - return buildstep.Scan{ +func (s *steps) scan() buildrun.Scan { + return buildrun.Scan{ Keys: func() ([]string, error) { s.mu.Lock() defer s.mu.Unlock() diff --git a/modules/mesh-build-go/buildstep/digestshape_test.go b/modules/mesh-build-go/buildstep/digestshape_test.go index aa2b757db..af9511d0d 100644 --- a/modules/mesh-build-go/buildstep/digestshape_test.go +++ b/modules/mesh-build-go/buildstep/digestshape_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // credentialField is the node runtime's rule for a state value (mesh-tools node-tools/internal/bus/state.go, novox/hq @@ -59,11 +59,11 @@ func credentialField(value json.RawMessage) string { // runtimeState is a steps state that refuses a value as the node runtime does, and keeps every value it took. type runtimeState struct { mu sync.Mutex - took map[string][]delivery.Step + took map[string][]sdkstep.Step refused int } -func newRuntimeState() *runtimeState { return &runtimeState{took: map[string][]delivery.Step{}} } +func newRuntimeState() *runtimeState { return &runtimeState{took: map[string][]sdkstep.Step{}} } func (s *runtimeState) put(key string, value any) (uint64, error) { raw, _ := value.(json.RawMessage) @@ -73,7 +73,7 @@ func (s *runtimeState) put(key string, value any) (uint64, error) { s.mu.Unlock() return 0, fmt.Errorf("steps.%s carries a field %q, which names a credential", key, f) } - st, err := delivery.DecodeStep(raw) + st, err := sdkstep.DecodeStep(raw) if err != nil { return 0, err } @@ -83,12 +83,12 @@ func (s *runtimeState) put(key string, value any) (uint64, error) { return uint64(len(s.took[key])), nil } -func (s *runtimeState) last(key string) (delivery.Step, bool) { +func (s *runtimeState) last(key string) (sdkstep.Step, bool) { s.mu.Lock() defer s.mu.Unlock() t := s.took[key] if len(t) == 0 { - return delivery.Step{}, false + return sdkstep.Step{}, false } return t[len(t)-1], true } @@ -109,8 +109,8 @@ func TestADigestOfAPackageNamedTokenIsStoredAsAList(t *testing.T) { digests: map[string]string{"x/internal/token": "d-token", "x/pkg/a": "d-a"}, tests: map[string]bool{"x/internal/token": true, "x/pkg/a": true}, } - out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, - Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}})) + out, err := RunAsk(context.Background(), r.steps(), ask(sdkstep.CheckJob{Levels: []string{sdkstep.LevelUnit}, + Fetch: sdkstep.Fetch{Repository: "novox/x", Commit: runCommit}})) if err != nil { t.Fatal(err) } @@ -119,21 +119,21 @@ func TestADigestOfAPackageNamedTokenIsStoredAsAList(t *testing.T) { t.Fatalf("the rule does not refuse the form before issue 1930 (%s): the test proves nothing", old) } st := newRuntimeState() - if err := finish(st.put, "shanks.a1", delivery.Step{ID: "a1", Tool: "check ask", Node: "shanks", State: delivery.StepRunning, Started: time.Now()}, out, nil, t.Logf); err != nil { + if err := finish(st.put, "shanks.a1", sdkstep.Step{ID: "a1", Tool: "check ask", Node: "shanks", State: sdkstep.StepRunning, Started: time.Now()}, out, nil, t.Logf); err != nil { t.Fatal(err) } s, ok := st.last("shanks.a1") - if !ok || s.State != delivery.StepPassed || st.refused != 0 { + if !ok || s.State != sdkstep.StepPassed || st.refused != 0 { t.Fatalf("the end %+v (written %v, refused %d), want it passed and taken at once", s, ok, st.refused) } - var a delivery.CheckAnswer + var a sdkstep.CheckAnswer if err := json.Unmarshal(s.Result, &a); err != nil { t.Fatal(err) } if len(a.Levels) != 1 || a.Levels[0].Digest == nil { t.Fatalf("levels %+v, want the unit level with its digest", a.Levels) } - want := []delivery.PackageDigest{{Package: "x/internal/token", Digest: "d-token"}, {Package: "x/pkg/a", Digest: "d-a"}} + want := []sdkstep.PackageDigest{{Package: "x/internal/token", Digest: "d-token"}, {Package: "x/pkg/a", Digest: "d-a"}} if got := a.Levels[0].Digest.Packages; fmt.Sprint(got) != fmt.Sprint(want) { t.Errorf("packages %v, want %v", got, want) } @@ -148,9 +148,9 @@ func TestARefusedEndIsSaidAndEndsTheStepFailed(t *testing.T) { fastEnds(t) big := errors.New("the value carries a field \"x/internal/token\", which names a credential") var refused int - var stored []delivery.Step + var stored []sdkstep.Step put := func(key string, value any) (uint64, error) { - st, _ := delivery.DecodeStep(value.(json.RawMessage)) + st, _ := sdkstep.DecodeStep(value.(json.RawMessage)) if len(st.Result) > 0 { refused++ return 0, big @@ -160,7 +160,7 @@ func TestARefusedEndIsSaidAndEndsTheStepFailed(t *testing.T) { } var logged []string logf := func(format string, args ...any) { logged = append(logged, fmt.Sprintf(format, args...)) } - err := finish(put, "shanks.a2", delivery.Step{ID: "a2", Tool: "check ask", Node: "shanks", State: delivery.StepRunning, Started: time.Now()}, map[string]any{"levels": 1}, nil, logf) + err := finish(put, "shanks.a2", sdkstep.Step{ID: "a2", Tool: "check ask", Node: "shanks", State: sdkstep.StepRunning, Started: time.Now()}, map[string]any{"levels": 1}, nil, logf) if err != nil { t.Fatalf("finish answered %v, want the small end written", err) } @@ -168,7 +168,7 @@ func TestARefusedEndIsSaidAndEndsTheStepFailed(t *testing.T) { t.Fatalf("refused %d, stored %d, want %d refusals and one small end", refused, len(stored), EndAttempts) } s := stored[0] - if s.State != delivery.StepFailed || len(s.Result) != 0 || !strings.Contains(s.Reason, "could not be written") || + if s.State != sdkstep.StepFailed || len(s.Result) != 0 || !strings.Contains(s.Reason, "could not be written") || !strings.Contains(s.Reason, big.Error()) || !strings.Contains(s.Reason, "passed") { t.Errorf("the small end %+v, want failed, no result, naming the write's error and that it passed", s) } @@ -176,7 +176,7 @@ func TestARefusedEndIsSaidAndEndsTheStepFailed(t *testing.T) { t.Errorf("logged %q, want the refusal said", logged) } none := func(string, any) (uint64, error) { return 0, errors.New("the bus is gone") } - if err := finish(none, "shanks.a3", delivery.Step{ID: "a3", Tool: "check ask", Node: "shanks", Started: time.Now()}, nil, nil, logf); err == nil || !strings.Contains(err.Error(), "the bus is gone") { + if err := finish(none, "shanks.a3", sdkstep.Step{ID: "a3", Tool: "check ask", Node: "shanks", Started: time.Now()}, nil, nil, logf); err == nil || !strings.Contains(err.Error(), "the bus is gone") { t.Errorf("finish with no write possible answered %v, want the error", err) } } @@ -192,16 +192,16 @@ func TestAnAskWhoseEndCannotBeWrittenIsNotAcked(t *testing.T) { }{{"the small end written", true, true}, {"no end written", false, false}} { t.Run(tc.name, func(t *testing.T) { src := NewFakeAsks() - a := testAsk(delivery.AskCheck) + a := testAsk(sdkstep.AskCheck) _ = src.PublishAsk(a) var mu sync.Mutex - var ends []delivery.Step + var ends []sdkstep.Step put := func(key string, value any) (uint64, error) { - st, err := delivery.DecodeStep(value.(json.RawMessage)) + st, err := sdkstep.DecodeStep(value.(json.RawMessage)) if err != nil { return 0, err } - if st.State != delivery.StepRunning && (len(st.Result) > 0 || !tc.smallOK) { + if st.State != sdkstep.StepRunning && (len(st.Result) > 0 || !tc.smallOK) { return 0, errors.New("refused") } mu.Lock() @@ -209,9 +209,9 @@ func TestAnAskWhoseEndCannotBeWrittenIsNotAcked(t *testing.T) { mu.Unlock() return 1, nil } - p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: put, Capacity: func() int { return 1 }, + p := &Puller{Source: src, Language: sdkstep.AskGo, Node: "shanks", Put: put, Capacity: func() int { return 1 }, PullWait: 10 * time.Millisecond, Logf: t.Logf, - Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return map[string]any{"ran": true}, nil }} + Run: func(ctx context.Context, a sdkstep.StepAsk) (any, error) { return map[string]any{"ran": true}, nil }} ctx, cancel := context.WithCancel(context.Background()) defer cancel() go p.Pull(ctx) @@ -225,7 +225,7 @@ func TestAnAskWhoseEndCannotBeWrittenIsNotAcked(t *testing.T) { defer mu.Unlock() if tc.smallOK { last := ends[len(ends)-1] - if last.State != delivery.StepFailed || !strings.Contains(last.Reason, "refused") { + if last.State != sdkstep.StepFailed || !strings.Contains(last.Reason, "refused") { t.Errorf("the end %+v, want the small failed end", last) } } diff --git a/modules/mesh-build-go/buildstep/fetch.go b/modules/mesh-build-go/buildstep/fetch.go index 4135bdbab..c61150f2f 100644 --- a/modules/mesh-build-go/buildstep/fetch.go +++ b/modules/mesh-build-go/buildstep/fetch.go @@ -9,6 +9,7 @@ import ( "regexp" "strings" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -71,7 +72,7 @@ func FetchCommit(ctx context.Context, run forgekey.Runner, dir, remote, commit, full := append([]string{"-c", "credential.helper=", "-C", dir}, args...) r := run(ctx, env, "git", full...) if r.Status != 0 || r.Err != "" { - return "", fmt.Errorf("git %s: %s", args[0], forgekey.Said(r.Err+"\n"+r.Stderr)) + return "", fmt.Errorf("git %s: %s", args[0], dforgekey.Said(r.Err+"\n"+r.Stderr)) } return r.Stdout, nil } @@ -132,7 +133,7 @@ func UpdateMirror(ctx context.Context, run forgekey.Runner, mirror, remote, comm if verb == "-C" && len(args) > 2 { // the mirror's commands are named by what they do, not by -C verb = args[2] } - return fmt.Errorf("git %s: %s", verb, forgekey.Said(r.Err+"\n"+r.Stderr)) + return fmt.Errorf("git %s: %s", verb, dforgekey.Said(r.Err+"\n"+r.Stderr)) } return nil } diff --git a/modules/mesh-build-go/buildstep/generated.go b/modules/mesh-build-go/buildstep/generated.go new file mode 100644 index 000000000..a3121c061 --- /dev/null +++ b/modules/mesh-build-go/buildstep/generated.go @@ -0,0 +1,164 @@ +package buildstep + +// **Regenerate and compare** (CAT-BG5, CAT-BN2; novox/hq ADR 0608 §5, design 26 *Contracts modules*). A module whose +// package artifact names `generated-from` holds contracts only: every file under its generated roots is written by the +// seat generator (package seatgen beside this file, command cmd/seatgen) from the definitions in that directory. A build module regenerates them with the generator +// it was built with (its version is this package's requirement, so a new generator is a new build module and a new +// fingerprint of what it builds) and names each file out of step: one that differs, one missing, one under a generated +// root the generator did not write. The test step fails on any; a trunk build's ask answers them in its out-of-step +// (sdkstep.BuildAnswer.OutOfStep), so the builder's build end raises generated-types-out-of-step even where no test +// step runs. Each build module compares its own roots: mesh-build-go `go`, mesh-build-node `ts`. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/seatgen" + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// GeneratedRootGo and GeneratedRootTS are the generated roots of a contracts module, one per build module. +const ( + GeneratedRootGo = "go" + GeneratedRootTS = "ts" +) + +var goModuleLine = regexp.MustCompile(`(?m)^module\s+(\S+)`) + +// OutOfStep is every file under root, a path from the module's directory, that the generator writes otherwise than the +// module holds it, for each `generated-from` its package artifacts name, sorted and each once; none for a module naming +// none. The generated trees' names are read from the seats already generated in (generatedNames). +func OutOfStep(moduleDir, root string) ([]string, error) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if errors.Is(err, os.ErrNotExist) { + return nil, nil // no manifest here: nothing names generated-from + } + if err != nil { + return nil, fmt.Errorf("the module's manifest cannot be read for its generated files: %w", err) + } + var m catalogue.Manifest + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("the module's manifest does not parse: %w", err) + } + froms := map[string]bool{} + if m.Build != nil { + for _, a := range m.Build.Artifacts { + if a.GeneratedFrom != "" { + froms[filepath.Clean(a.GeneratedFrom)] = true + } + } + } + seen := map[string]bool{} + for from := range froms { + if filepath.IsAbs(from) || from == ".." || strings.HasPrefix(from, "../") { + return nil, fmt.Errorf("generated-from %q is not inside the module", from) + } + dir := filepath.Join(moduleDir, from) + goModule, tsPackage, err := generatedNames(dir) + if err != nil { + return nil, err + } + g, err := seatgen.Load(dir, goModule, tsPackage) + if err != nil { + return nil, fmt.Errorf("the seat definitions in %s do not load: %w", from, err) + } + files, err := seatgen.Generate(g) + if err != nil { + return nil, fmt.Errorf("the seat definitions in %s do not generate: %w", from, err) + } + out, err := seatgen.Compare(dir, files) + if err != nil { + return nil, err + } + for _, line := range out { + p, _, _ := strings.Cut(line, ": ") + if p == root || strings.HasPrefix(p, root+"/") { + seen[filepath.ToSlash(filepath.Join(from, p))] = true + } + } + } + paths := make([]string, 0, len(seen)) + for p := range seen { + paths = append(paths, p) + } + sort.Strings(paths) + return paths, nil +} + +// generatedNames reads the names the generated trees are rooted at from the seats already generated in dir: a seat's Go +// module is `/seats/` and its npm package `-` (a contracts module has no root +// package of either kind, ADR 0635 §1). A module with no seat generated yet gives none, and the generator says so only +// if a seat is defined whole. +func generatedNames(dir string) (goModule, tsPackage string, err error) { + names, _ := filepath.Glob(filepath.Join(dir, "seats", "*", "seat.json")) + for _, p := range names { + seat := filepath.Base(filepath.Dir(p)) + pkg := strings.ReplaceAll(seat, "-", "") + if goModule == "" { + if raw, err := os.ReadFile(filepath.Join(dir, GeneratedRootGo, "seats", pkg, "go.mod")); err == nil { + if m := goModuleLine.FindSubmatch(raw); m != nil { + goModule = strings.TrimSuffix(string(m[1]), "/seats/"+pkg) + } + } + } + if tsPackage == "" { + if raw, err := os.ReadFile(filepath.Join(dir, GeneratedRootTS, "seats", pkg, "package.json")); err == nil { + var p struct { + Name string `json:"name"` + } + if json.Unmarshal(raw, &p) == nil { + tsPackage = strings.TrimSuffix(p.Name, "-"+seat) + } + } + } + } + return goModule, tsPackage, nil +} + +// ModuleDirs is every directory in tree that holds a module.json, in any folder (ADR 0634 §8: a module is found by its +// manifest, whether it lies at the root, under modules/ or in a folder of another module), the tree's own first, then +// sorted. Hidden directories, node_modules and testdata are not entered. +func ModuleDirs(tree string) ([]string, error) { + var out []string + err := filepath.WalkDir(tree, func(p string, e os.DirEntry, err error) error { + if err != nil { + return err + } + if e.IsDir() { + if n := e.Name(); p != tree && (strings.HasPrefix(n, ".") || n == "node_modules" || n == "testdata") { + return filepath.SkipDir + } + return nil + } + if e.Name() == "module.json" { + out = append(out, filepath.Dir(p)) + } + return nil + }) + sort.Strings(out) + return out, err +} + +// CompareStep is AskSteps.Compare for a build module comparing root: it reads {"workspace", "module"}, the workspace's +// tree where tree says it is (the build module's own layout), and answers {"out-of-step": [...]}. +func CompareStep(root string, tree func(workspace string) string) StepFunc { + return func(_ context.Context, args map[string]any) (any, error) { + ws, _ := args["workspace"].(string) + mod, _ := args["module"].(string) + if ws == "" { + return nil, Refuse("the compare names no workspace") + } + paths, err := OutOfStep(filepath.Join(tree(ws), filepath.FromSlash(mod)), root) + if err != nil { + return nil, err + } + return map[string]any{"out-of-step": paths}, nil + } +} diff --git a/modules/mesh-build-go/buildstep/generated_test.go b/modules/mesh-build-go/buildstep/generated_test.go new file mode 100644 index 000000000..a1eb3efcf --- /dev/null +++ b/modules/mesh-build-go/buildstep/generated_test.go @@ -0,0 +1,133 @@ +package buildstep + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + + "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/seatgen" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" +) + +// contracts writes a contracts module of one seat, its generated files as the generator writes them, and answers its +// directory and the generated paths. +func contracts(t *testing.T, manifest string) (string, []string) { + t.Helper() + dir := t.TempDir() + for p, text := range map[string]string{ + "module.json": manifest, + "seats/one-store/seat.json": `{"format":1,"seat":"one-store","scope":"mesh","grain":"whole","domain":"Module","decision":"ADR 1","verbs":[{"name":"query","input":{"type":"object"},"answer":{"type":"object"}}]}`, + // A definition at the seat-list grain is read and generates nothing (ADR 0635 §1, §2). + "seats/other/seat.json": `{"format":1,"seat":"other","scope":"mesh","grain":"seat-list","domain":"Module","decision":"ADR 1","verbs":[{"name":"ask","input":{"type":"object"}}]}`, + } { + writeContract(t, filepath.Join(dir, p), text) + } + m, err := seatgen.Load(dir, "example.org/c/go", "@example/c") + if err != nil { + t.Fatal(err) + } + files, err := seatgen.Generate(m) + if err != nil { + t.Fatal(err) + } + var paths []string + for p, b := range files { + writeContract(t, filepath.Join(dir, p), string(b)) + paths = append(paths, p) + } + return dir, paths +} + +func writeContract(t *testing.T, path, text string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(text), 0o644); err != nil { + t.Fatal(err) + } +} + +// **CAT-BG5, CAT-BN2**: a contracts module's generated files are regenerated and compared, each build module its own +// root; a file that differs, one missing and one the generator did not write are each named; a clean tree and a module +// naming no generated-from name none; a build ask answers them in its out-of-step. +func TestAContractsModulesGeneratedFilesAreComparedWithTheGenerator(t *testing.T) { + marked := `{"module":"c","build":{"artifacts":[{"name":"go","kind":"package","language":"go","paths":["go"],"generated-from":"."}]}}` + dir, paths := contracts(t, marked) + var goFile, tsFile string + for _, p := range paths { + if strings.HasPrefix(p, "go/") && strings.HasSuffix(p, ".go") && goFile == "" { + goFile = p + } + if strings.HasPrefix(p, "ts/") && strings.HasSuffix(p, ".ts") && tsFile == "" { + tsFile = p + } + } + for _, p := range paths { + if strings.Contains(p, "other") || p == "go/go.mod" || p == "ts/package.json" { + t.Errorf("%s is generated: a seat-list definition generates nothing and a contracts module has no root package", p) + } + } + if goFile == "" || tsFile == "" { + t.Fatalf("the generator wrote no Go or no TypeScript file: %v", paths) + } + for _, root := range []string{GeneratedRootGo, GeneratedRootTS} { + if got, err := OutOfStep(dir, root); err != nil || len(got) != 0 { + t.Fatalf("a clean tree, root %s: %v %v", root, got, err) + } + } + writeContract(t, filepath.Join(dir, goFile), "package changed\n") + writeContract(t, filepath.Join(dir, "go/extra.go"), "package extra\n") + writeContract(t, filepath.Join(dir, tsFile), "// changed\n") + got, err := OutOfStep(dir, GeneratedRootGo) + if want := sorted(goFile, "go/extra.go"); err != nil || !reflect.DeepEqual(got, want) { + t.Errorf("Go root: %v %v, want %v", got, err, want) + } + if got, _ := OutOfStep(dir, GeneratedRootTS); !reflect.DeepEqual(got, []string{tsFile}) { + t.Errorf("TypeScript root: %v, want %s alone", got, tsFile) + } + _ = os.Remove(filepath.Join(dir, goFile)) + if got, _ := OutOfStep(dir, GeneratedRootGo); !reflect.DeepEqual(got, sorted(goFile, "go/extra.go")) { + t.Errorf("a missing file is not named: %v", got) + } + plain, _ := contracts(t, `{"module":"p","build":{"artifacts":[{"name":"go","kind":"package","language":"go","paths":["go"]}]}}`) + writeContract(t, filepath.Join(plain, "go/extra.go"), "package extra\n") + if got, err := OutOfStep(plain, GeneratedRootGo); err != nil || len(got) != 0 { + t.Errorf("a module naming no generated-from compared: %v %v", got, err) + } + + // The build ask answers them beside what it built, and with a failed build too. + built := 0 + steps := AskSteps{Compare: CompareStep(GeneratedRootGo, func(ws string) string { return ws }), + Fetch: func(context.Context, map[string]any) (any, error) { + return map[string]any{"workspace": filepath.Dir(dir)}, nil + }, + Build: func(context.Context, map[string]any) (any, error) { + if built++; built > 1 { + return nil, errors.New("the second artifact does not build") + } + return map[string]any{"digest": "sha256:aa"}, nil + }} + job := sdkstep.BuildJob{Module: "c", Path: filepath.Base(dir), Fetch: &sdkstep.Fetch{Repository: "novox/c", Commit: "0123456789abcdef0123456789abcdef01234567"}, + Artifacts: []sdkstep.AskArtifact{{Artifact: "go"}}} + out, err := RunAsk(context.Background(), steps, ask(job)) + if a, ok := out.(sdkstep.BuildAnswer); err != nil || !ok || !reflect.DeepEqual(a.OutOfStep, sorted(goFile, "go/extra.go")) || len(a.Artifacts) != 1 { + t.Errorf("the build ask's out-of-step: %+v %v", out, err) + } + job.Artifacts = append(job.Artifacts, sdkstep.AskArtifact{Artifact: "go-other"}) + built = 0 + out, err = RunAsk(context.Background(), steps, ask(job)) + if a, ok := out.(sdkstep.BuildAnswer); err == nil || !ok || len(a.OutOfStep) != 2 { + t.Errorf("a failed build answered no out-of-step: %+v %v", out, err) + } +} + +func sorted(paths ...string) []string { + sort.Strings(paths) + return paths +} diff --git a/modules/mesh-build-go/buildstep/go.mod b/modules/mesh-build-go/buildstep/go.mod index d61305e9f..e9d32952a 100644 --- a/modules/mesh-build-go/buildstep/go.mod +++ b/modules/mesh-build-go/buildstep/go.mod @@ -3,20 +3,37 @@ module git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/clients/gopackageregistryclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/clients/npmpackageregistryclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/clients/ociartifactregistryclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/gopackageregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildgo v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildnode v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/npmpackageregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 github.com/nats-io/nats.go v1.54.0 ) require ( - github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect + github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/minio/highwayhash v1.0.4 // indirect - github.com/nats-io/jwt/v2 v2.8.1 // indirect + github.com/nats-io/jwt/v2 v2.8.2 // indirect github.com/nats-io/nats-server/v2 v2.11.17 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect golang.org/x/crypto v0.57.0 // indirect golang.org/x/sys v0.48.0 // indirect - golang.org/x/time v0.15.0 // indirect + golang.org/x/time v0.16.0 // indirect +) + +require ( + git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 // indirect ) diff --git a/modules/mesh-build-go/buildstep/go.sum b/modules/mesh-build-go/buildstep/go.sum index 43cc4ec77..8ca93dfd0 100644 --- a/modules/mesh-build-go/buildstep/go.sum +++ b/modules/mesh-build-go/buildstep/go.sum @@ -1,15 +1,13 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= -github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= -github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc= +github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc= github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= @@ -23,5 +21,5 @@ golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZ golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= diff --git a/modules/mesh-build-go/buildstep/gotest.go b/modules/mesh-build-go/buildstep/gotest.go index 0f6b59bb1..9c15d72c2 100644 --- a/modules/mesh-build-go/buildstep/gotest.go +++ b/modules/mesh-build-go/buildstep/gotest.go @@ -8,7 +8,7 @@ import ( "bufio" "encoding/json" "fmt" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "regexp" "slices" "strings" diff --git a/modules/mesh-build-go/buildstep/guard.go b/modules/mesh-build-go/buildstep/guard.go index a9dc49cd4..59f844c9b 100644 --- a/modules/mesh-build-go/buildstep/guard.go +++ b/modules/mesh-build-go/buildstep/guard.go @@ -5,14 +5,14 @@ import ( "encoding/json" "fmt" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // Guard is a build module's check that the toolchain installed on its node is the one its recipe builds with // (novox/hq ADR 0469 §1; issue 1473). The same guard serves ADR 0470 §4's postgres major for the integration // level, with its own Probe and Want. type Guard struct { - // Language is the toolchain's key part: go or typescript (delivery.ToolchainKey). + // Language is the toolchain's key part: go or typescript (ToolchainKey). Language string // Want is the toolchain version the recipe builds with, and Recipe the recipe's own version. The indexer // compares both texts as they are written here. @@ -20,7 +20,7 @@ type Guard struct { // Probe reads the installed version (go version, node --version). Probe func(ctx context.Context) (installed string, err error) Node string - // Put is the toolchains state's Put (stdio.State(delivery.ToolchainsState).Put). + // Put is the toolchains state's Put (stdio.State(the seat's toolchains state).Put). Put Put } @@ -33,11 +33,11 @@ func (g Guard) Check(ctx context.Context) error { if err != nil { return &Refusal{Text: fmt.Sprintf("the installed %s version could not be read: %v", g.Language, err), Want: g.Want} } - key, err := delivery.ToolchainKey(g.Node, g.Language) + key, err := sdkstep.ToolchainKey(g.Node, g.Language) if err != nil { return err } - raw, err := delivery.EncodeToolchain(delivery.Toolchain{Language: g.Language, Version: installed, Recipe: g.Recipe, At: now().UTC()}) + raw, err := sdkstep.EncodeToolchain(sdkstep.Toolchain{Language: g.Language, Version: installed, Recipe: g.Recipe, At: now().UTC()}) if err != nil { return err } diff --git a/modules/mesh-build-go/buildstep/guard_test.go b/modules/mesh-build-go/buildstep/guard_test.go index 30514b1bf..fe8bad19e 100644 --- a/modules/mesh-build-go/buildstep/guard_test.go +++ b/modules/mesh-build-go/buildstep/guard_test.go @@ -7,13 +7,13 @@ import ( "strings" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) -type toolchainKeys map[string]delivery.Toolchain +type toolchainKeys map[string]sdkstep.Toolchain func (k toolchainKeys) put(key string, value any) (uint64, error) { - tc, err := delivery.DecodeToolchain(value.(json.RawMessage)) + tc, err := sdkstep.DecodeToolchain(value.(json.RawMessage)) if err != nil { return 0, err } diff --git a/modules/mesh-build-go/buildstep/latepublish.go b/modules/mesh-build-go/buildstep/latepublish.go new file mode 100644 index 000000000..46bf19c2f --- /dev/null +++ b/modules/mesh-build-go/buildstep/latepublish.go @@ -0,0 +1,91 @@ +package buildstep + +// **The late publish** (novox/hq ADR 0617 §1, design 51 *The versions*, *Publishing*; CAT-BG2, CAT-BI1): a trunk build +// whose number the builder did not know yet publishes its digest unnumbered; the builder says version.numbered once it +// numbers that fingerprint, and the build module publishes the held digest again under that version. Nothing travels: +// the publish names the one part by its digest, which the holder holds, so it answers held and tags the version (the +// holder asks builder_versions first, ADR 0605 §4). The bytes never stayed on the node, so a holder that no longer holds +// the digest is said and the event offered again. Every instance may hear the same event; the publish is the same call +// under the same transfer name, so a second one is answered held. One place for every build module: mesh-build-go's +// bundles and archives on oci-artifact-registry, mesh-build-image's images on container-image-registry. + +import ( + "context" + "errors" + "fmt" + "io" + "log" + "slices" + "time" + + reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-delivery/sdk/parts" + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// NumberedPattern is the event type a build module consumes for the late publish, as its manifest's `consumes` names it. +const NumberedPattern = builds.BuilderModule + "." + builds.VersionNumberedEvent + +// errNotHere is a held part's Open: its bytes did not stay on the node. +var errNotHere = errors.New("the built bytes did not stay on the node; the holder must hold the digest") + +// LatePublish publishes a numbered build's held digest under its version on seat, from the builder's version.numbered +// body, when its kind is one of kinds. It answers false, nil for an event that is not the module's to publish: another +// kind, another seat (an event naming none is the artifact seat's, as the builder wrote it before images), or a version +// numbered with no digest. +func LatePublish(ctx context.Context, call SeatCall, body []byte, seat string, kinds ...string) (bool, error) { + n, err := builds.DecodeVersionNumbered(body) + if err != nil { + return false, err + } + on := n.Seat + if on == "" { + on = ArtifactSeat + } + if n.Digest == "" || on != seat || !slices.Contains(kinds, n.Kind) { + return false, nil + } + in := reg.PublishInput{Name: n.Name, Kind: n.Kind, Version: n.Version, Tag: n.Tag, Digest: n.Digest, + Fingerprint: n.Fingerprint, Commit: n.Commit, Transfer: TransferName(n.Name, "v"+n.Version, n.Digest)} + sent, err := OnSeat(ctx, seat, "late publish of "+n.Name+" "+n.Version+" at "+n.Digest, func() (parts.Sent, error) { + return PublishParts(ctx, call, seat, in, []parts.Part{{Digest: n.Digest, + Open: func() (io.ReadCloser, error) { return nil, errNotHere }}}) + }, nil) + if err != nil { + return false, fmt.Errorf("the late publish of %s@%s (%s): %w", n.Name, n.Version, n.Digest, err) + } + if len(sent.Parts) > 0 { + return false, fmt.Errorf("the late publish of %s@%s sent parts it should not have", n.Name, n.Version) + } + return true, nil +} + +// HearNumbered subscribes late to the builder's version.numbered, asking again until the runtime takes it, and says +// each publish and each failure; an error is the event's, which is offered again. +func HearNumbered(ctx context.Context, late func(ctx context.Context, body []byte) (bool, error)) { + handle := func(e stdio.Envelope) error { + did, err := late(ctx, e.Body) + if err != nil { + log.Printf("%v", err) + return err + } + if did { + log.Printf("published a numbered build's held digest under its version (%s)", e.Headers["x-event-id"]) + } + return nil + } + for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { + err := stdio.Subscribe(NumberedPattern, handle) + if err == nil { + log.Printf("listening for the builder's %s", NumberedPattern) + return + } + log.Printf("not hearing the builder's %s yet (%v); asking again in %s", NumberedPattern, err, wait) + select { + case <-ctx.Done(): + return + case <-time.After(wait): + } + } +} diff --git a/modules/mesh-build-go/buildstep/merge.go b/modules/mesh-build-go/buildstep/merge.go index 86384b6d2..958750c62 100644 --- a/modules/mesh-build-go/buildstep/merge.go +++ b/modules/mesh-build-go/buildstep/merge.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -55,7 +56,7 @@ func MergeInto(ctx context.Context, run forgekey.Runner, dir, remote, base, comm full := append([]string{"-c", "credential.helper=", "-C", dir}, args...) r := run(ctx, env, "git", full...) if r.Status != 0 || r.Err != "" { - return "", fmt.Errorf("git %s: %s", args[0], forgekey.Said(r.Err+"\n"+r.Stderr)) + return "", fmt.Errorf("git %s: %s", args[0], dforgekey.Said(r.Err+"\n"+r.Stderr)) } return r.Stdout, nil } diff --git a/modules/mesh-build-go/buildstep/narrate.go b/modules/mesh-build-go/buildstep/narrate.go new file mode 100644 index 000000000..6e5cfbc95 --- /dev/null +++ b/modules/mesh-build-go/buildstep/narrate.go @@ -0,0 +1,106 @@ +package buildstep + +// **A build narrates its asks of a seat** (CAT-NR1; design 51 *Publishing*): asked, waiting on the seat (said again every +// NarrateEvery while it waits), stored, refused. Each line is the ask's phase (phase.go): logged with the ask's id and +// written to its running step key, so the builder and the delivery view read from the bus where a publish or a copy +// stands; outside an ask's run (a late publish, a tool called by hand) it is logged. One place for every build module: +// PublishOne and PublishFiles narrate through it, and a step that waits on a seat's event (an image's copy) uses +// NarrateSeat itself. + +import ( + "context" + "fmt" + "log" + "time" +) + +// NarrateEvery is how often a build says again that it still waits on a seat. +var NarrateEvery = 5 * time.Minute + +// narrateTicks is the clock of the waiting lines; a test hands its own. +var narrateTicks = func(every time.Duration) (<-chan time.Time, func()) { + t := time.NewTicker(every) + return t.C, t.Stop +} + +// SeatNarration is one ask of a build on a seat, said as it moves. +type SeatNarration struct { + ctx context.Context + seat, what string + asked time.Time + tick <-chan time.Time + stop func() +} + +// NarrateSeat says that the build asked seat for what (a verb and its object, such as "publish of nats/server") and +// starts the waiting lines; Stored or Refused ends it. +func NarrateSeat(ctx context.Context, seat, what string) *SeatNarration { + n := &SeatNarration{ctx: ctx, seat: seat, what: what, asked: now()} + n.tick, n.stop = narrateTicks(NarrateEvery) + n.say("asked %s: %s", seat, what) + return n +} + +// Waiting is the clock a waiting step selects on beside its outcome; on each tick it calls StillWaiting. +func (n *SeatNarration) Waiting() <-chan time.Time { return n.tick } + +// StillWaiting says that the build still waits on the seat, and since when. +func (n *SeatNarration) StillWaiting() { + n.say("waiting on %s: %s, %s since asked", n.seat, n.what, now().Sub(n.asked).Round(time.Second)) +} + +// Stored says that the seat holds what the build asked it to keep (how: where, or what travelled). +func (n *SeatNarration) Stored(how string) { + n.stop() + if how != "" { + how = " (" + how + ")" + } + n.say("stored on %s: %s%s", n.seat, n.what, how) +} + +// Refused says that the seat did not keep it, and why. +func (n *SeatNarration) Refused(err error) { + n.stop() + n.say("refused by %s: %s: %v", n.seat, n.what, err) +} + +func (n *SeatNarration) say(format string, a ...any) { + line := fmt.Sprintf(format, a...) + if r, ok := n.ctx.Value(phaseKey{}).(*phases); ok && r != nil { + r.enter(line) + return + } + log.Print(line) +} + +// OnSeat runs call as one narrated ask of the build on seat: asked, waiting every NarrateEvery while call runs, then +// stored (how(answer)) or refused (its error). +func OnSeat[T any](ctx context.Context, seat, what string, call func() (T, error), how func(T) string) (T, error) { + n := NarrateSeat(ctx, seat, what) + type answer struct { + v T + err error + } + done := make(chan answer, 1) + go func() { + v, err := call() + done <- answer{v, err} + }() + for { + select { + case a := <-done: + if a.err != nil { + n.Refused(a.err) + } else { + h := "" + if how != nil { + h = how(a.v) + } + n.Stored(h) + } + return a.v, a.err + case <-n.Waiting(): + n.StillWaiting() + } + } +} diff --git a/modules/mesh-build-go/buildstep/narrate_test.go b/modules/mesh-build-go/buildstep/narrate_test.go new file mode 100644 index 000000000..5b27e9b60 --- /dev/null +++ b/modules/mesh-build-go/buildstep/narrate_test.go @@ -0,0 +1,107 @@ +package buildstep + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "testing" + "time" + + reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" +) + +// CAT-NR1: a build's publish on a seat says asked, waiting on the seat (on every tick of NarrateEvery while the holder +// has not answered), and stored or refused, each as the ask's phase written to its running step key. +func TestABuildNarratesItsPublishOnASeat(t *testing.T) { + digest := "sha256:" + strings.Repeat("c", 64) + for name, c := range map[string]struct { + answer error + ticks int + want []string + }{ + "stored": {want: []string{ + "asked go-package-registry: publish of mod 1.2.3 at " + digest, + "stored on go-package-registry: publish of mod 1.2.3 at " + digest + " (0 of its 1 parts sent, the rest held)"}}, + "refused": {answer: errors.New("the holder is down"), want: []string{ + "asked go-package-registry: publish of mod 1.2.3 at " + digest, + "refused by go-package-registry: publish of mod 1.2.3 at " + digest + ": go-package-registry's publish of mod: the holder is down"}}, + "waiting twice, then stored": {ticks: 2, want: []string{ + "asked go-package-registry: publish of mod 1.2.3 at " + digest, + "waiting on go-package-registry: publish of mod 1.2.3 at " + digest + ", 5m0s since asked", + "waiting on go-package-registry: publish of mod 1.2.3 at " + digest + ", 10m0s since asked", + "stored on go-package-registry: publish of mod 1.2.3 at " + digest + " (0 of its 1 parts sent, the rest held)"}}, + } { + t.Run(name, func(t *testing.T) { + clock := time.Date(2026, 11, 4, 16, 0, 0, 0, time.UTC) + var mu sync.Mutex + oldNow, oldTicks := now, narrateTicks + now = func() time.Time { mu.Lock(); defer mu.Unlock(); return clock } + tick := make(chan time.Time) + stopped := make(chan struct{}) + narrateTicks = func(every time.Duration) (<-chan time.Time, func()) { + if every != 5*time.Minute { + t.Errorf("the waiting lines come every %s, want 5m", every) + } + return tick, func() { close(stopped) } + } + t.Cleanup(func() { now, narrateTicks = oldNow, oldTicks }) + + var said []string + wrote := make(chan struct{}, 16) + step := sdkstep.Step{ID: "a1", Tool: "build", Node: "ace", State: sdkstep.StepRunning, Started: clock} + ph := &phases{key: "ace.a1", step: step, put: func(key string, value any) (uint64, error) { + var s sdkstep.Step + _ = json.Unmarshal(value.(json.RawMessage), &s) + phasesMu.Lock() + said = append(said, s.Phase) + phasesMu.Unlock() + wrote <- struct{}{} + return 1, nil + }} + release := make(chan struct{}) + call := func(key string, body any) (json.RawMessage, error) { + <-release + if c.answer != nil { + return nil, c.answer + } + return json.Marshal(reg.PublishAnswer{Held: true, Transfer: "t"}) + } + ctx := context.WithValue(context.Background(), phaseKey{}, ph) + done := make(chan error, 1) + go func() { done <- PublishOne(ctx, call, GoSeat, "mod", "package", "1.2.3", digest, "t", []byte("x")) }() + line := func() { + select { + case <-wrote: + case <-time.After(5 * time.Second): + t.Fatal("the publish said no line") + } + } + line() // asked, at the clock's start + for i := 0; i < c.ticks; i++ { + mu.Lock() + clock = clock.Add(5 * time.Minute) + mu.Unlock() + tick <- clock + line() // its waiting line + } + close(release) + err := <-done + if (err != nil) != (c.answer != nil) { + t.Fatalf("PublishOne = %v", err) + } + <-stopped + phasesMu.Lock() + defer phasesMu.Unlock() + // Each line is the step's phase, written to its key, in order. + if fmt.Sprint(said) != fmt.Sprint(c.want) { + t.Errorf("phases =\n%s\nwant\n%s", strings.Join(said, "\n"), strings.Join(c.want, "\n")) + } + }) + } +} + +var phasesMu sync.Mutex diff --git a/modules/mesh-build-go/buildstep/narrow.go b/modules/mesh-build-go/buildstep/narrow.go index b2980dc78..02066491f 100644 --- a/modules/mesh-build-go/buildstep/narrow.go +++ b/modules/mesh-build-go/buildstep/narrow.go @@ -2,7 +2,7 @@ package buildstep import ( "fmt" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "os" "path" "path/filepath" diff --git a/modules/mesh-build-go/buildstep/narrow_test.go b/modules/mesh-build-go/buildstep/narrow_test.go index 135bc6df8..621debfbc 100644 --- a/modules/mesh-build-go/buildstep/narrow_test.go +++ b/modules/mesh-build-go/buildstep/narrow_test.go @@ -1,7 +1,7 @@ package buildstep import ( - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "os" "path/filepath" "sort" diff --git a/modules/mesh-build-go/buildstep/phase.go b/modules/mesh-build-go/buildstep/phase.go index 130178a39..ffb0168fc 100644 --- a/modules/mesh-build-go/buildstep/phase.go +++ b/modules/mesh-build-go/buildstep/phase.go @@ -6,14 +6,14 @@ import ( "sync" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // **A running ask says its phases** (issue 1890, 2026-10-26 19:09: a mesh-catalog try's step on the workstation ran 30 minutes // and its module's journal held no line naming its ask, so nobody could tell whether it fetched, linted or tested). The // puller hands every ask's run a phase reporter in its context; RunAsk and a module's own step bodies call Phase as they // move (fetch and merge, a level's digest, lint, a level's tests), and each phase is logged with the ask's id and its -// time since the start, and written to the ask's running step key (delivery.Step.Phase), so its asker and the delivery +// time since the start, and written to the ask's running step key (sdkstep.Step.Phase), so its asker and the delivery // view read where it stands from the bus. type phaseKey struct{} @@ -30,7 +30,7 @@ type phases struct { mu sync.Mutex put Put key string - step delivery.Step + step sdkstep.Step ended bool logf func(format string, args ...any) prefix string @@ -59,7 +59,7 @@ func (r *phases) enter(phase string) { } // end stops the writes (the step's end is written after it) and answers the step with its last phase, for the end. -func (r *phases) end() delivery.Step { +func (r *phases) end() sdkstep.Step { r.mu.Lock() defer r.mu.Unlock() r.ended = true diff --git a/modules/mesh-build-go/buildstep/publish.go b/modules/mesh-build-go/buildstep/publish.go new file mode 100644 index 000000000..06e080ae8 --- /dev/null +++ b/modules/mesh-build-go/buildstep/publish.go @@ -0,0 +1,116 @@ +package buildstep + +// The publish of a done build's file through a registry seat (novox/hq design 51 *Publishing*, ADR 0611; CAT-BG1, +// CAT-BN3): from the build module's own process, after the build step ended, through the seat's verbs publish and +// publish-part, which each build module's manifest `invokes`, in chunks, only what the holder lacks. The holder checks +// the build with the builder and says published or refused on the seat's events. One place for every build module: it +// binds each seat to its client in mesh-contracts (ADR 0627 §1), over mesh-sdk's seat-neutral sender of parts (§2). + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os" + "strings" + + imageclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient" + goclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/gopackageregistryclient" + npmclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/npmpackageregistryclient" + ociclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/ociartifactregistryclient" + reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + goseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/gopackageregistry" + npmseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/npmpackageregistry" + ociseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry" + "git.novox.be/novox/mesh-delivery/sdk/parts" +) + +// The registry seats a build module publishes on. +const ( + ArtifactSeat = ociseat.Seat + ImageSeat = reg.Seat + GoSeat = goseat.Seat + NpmSeat = npmseat.Seat +) + +// SeatCall asks one verb of a seat, `seat:.`, with a body, and answers its JSON (the bundle's stdio.Ask). +type SeatCall = func(key string, body any) (json.RawMessage, error) + +// TransferName is a transfer's name, the build module's own: the name, a qualifier and the digest's head, safe as a +// transfer id. +func TransferName(name, qualifier, digest string) string { + head := strings.TrimPrefix(digest, "sha256:") + if len(head) > 16 { + head = head[:16] + } + return strings.NewReplacer("/", "-", ".", "-", " ", "-", "*", "-", ">", "-", "@", "-").Replace(name + "-" + qualifier + "-" + head) +} + +// PublishOne publishes body, of digest, as name (at version, when it has one) of kind on seat, in one part. +func PublishOne(ctx context.Context, call SeatCall, seat, name, kind, version, digest, transfer string, body []byte) error { + in := reg.PublishInput{Name: name, Kind: kind, Version: version, Digest: digest, Transfer: transfer} + _, err := OnSeat(ctx, seat, publishWhat(in), func() (parts.Sent, error) { + return PublishParts(ctx, call, seat, in, []parts.Part{{Digest: digest, Size: int64(len(body)), + Open: func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil }}}) + }, SentHow(1)) + return err +} + +// publishWhat names a publish in a narration: its name, its version when it has one, and its digest. +func publishWhat(in reg.PublishInput) string { + what := "publish of " + in.Name + if in.Version != "" { + what += " " + in.Version + } + return what + " at " + in.Digest +} + +// SentHow says what of a publish of parts travelled, for its stored line. +func SentHow(n int) func(parts.Sent) string { + return func(s parts.Sent) string { + return fmt.Sprintf("%d of its %d parts sent, the rest held", len(s.Parts), n) + } +} + +// PublishParts publishes in with ps as its parts on seat, through that seat's client: in's fields are the seat's own +// (the four registry seats share the publish verb's input, design 51), the parts are written by the client. +func PublishParts(ctx context.Context, call SeatCall, seat string, in reg.PublishInput, ps []parts.Part) (parts.Sent, error) { + switch seat { + case ImageSeat: + s, err := imageclient.Publish(ctx, imageclient.Call(call), in, ps) + return s.Sent, err + case ArtifactSeat: + s, err := ociclient.Publish(ctx, ociclient.Call(call), ociseat.PublishInput{Name: in.Name, Kind: in.Kind, Version: in.Version, + Tag: in.Tag, Digest: in.Digest, Fingerprint: in.Fingerprint, Commit: in.Commit, Transfer: in.Transfer}, ps) + return s.Sent, err + case GoSeat: + s, err := goclient.Publish(ctx, goclient.Call(call), goseat.PublishInput{Name: in.Name, Kind: in.Kind, Version: in.Version, + Tag: in.Tag, Digest: in.Digest, Fingerprint: in.Fingerprint, Commit: in.Commit, Transfer: in.Transfer}, ps) + return s.Sent, err + case NpmSeat: + s, err := npmclient.Publish(ctx, npmclient.Call(call), npmseat.PublishInput{Name: in.Name, Kind: in.Kind, Version: in.Version, + Tag: in.Tag, Digest: in.Digest, Fingerprint: in.Fingerprint, Commit: in.Commit, Transfer: in.Transfer}, ps) + return s.Sent, err + } + return parts.Sent{}, fmt.Errorf("%s is no registry seat a build publishes on", seat) +} + +// File is one part of a publish kept on disk: an image's config, layer or manifest, by its digest and size. +type File struct { + Digest string + Size int64 + Path string +} + +// PublishFiles publishes in with files as its parts (an image's config, layers and manifest, CAT-BI1): the holder +// answers the parts it lacks, and only those are read and sent. +func PublishFiles(ctx context.Context, call SeatCall, seat string, in reg.PublishInput, files []File) (parts.Sent, error) { + ps := make([]parts.Part, 0, len(files)) + for _, f := range files { + ps = append(ps, parts.Part{Digest: f.Digest, Size: f.Size, Open: func() (io.ReadCloser, error) { return os.Open(f.Path) }}) + } + return OnSeat(ctx, seat, publishWhat(in), func() (parts.Sent, error) { + return PublishParts(ctx, call, seat, in, ps) + }, SentHow(len(files))) +} diff --git a/modules/mesh-build-go/buildstep/pull.go b/modules/mesh-build-go/buildstep/pull.go index 9f5ed04b4..b17eb790f 100644 --- a/modules/mesh-build-go/buildstep/pull.go +++ b/modules/mesh-build-go/buildstep/pull.go @@ -5,14 +5,14 @@ import ( "encoding/json" "errors" "fmt" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" "sort" "strings" "sync" "time" + buildrun "git.novox.be/novox/mesh-delivery/sdk/buildrun" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/delivery" ) // **The pull loop the build modules share** (novox/hq ADR 0525 §4-§6, ADR 0536 §4; issue 1887). A build module on a node @@ -55,23 +55,23 @@ var ( // Puller pulls a build seat's asks and runs them. type Puller struct { Source AskSource - Language string // the seat's language: delivery.AskGo, AskNode or AskImage + Language string // the seat's language: sdkstep.AskGo, AskNode or AskImage Node string // the node this holder runs on, the first part of its step keys Put Put // writes the module's steps state // Capacity is how many asks the node runs at once; read before every pull, so a change takes effect at the next // one. 0 pulls nothing. Capacity func() int // Run runs one ask's job; a *Refusal ends its step refused. Its result is the step's result. - Run func(ctx context.Context, ask delivery.StepAsk) (any, error) + Run func(ctx context.Context, ask sdkstep.StepAsk) (any, error) // PullWait and Heartbeat default to DefaultPullWait and DefaultHeartbeat. PullWait time.Duration Heartbeat time.Duration Logf func(format string, args ...any) // Load reads one key of the state the node's pause is kept in (nil and no error when it is absent). When set, Pull - // reads the node's pause key (delivery.PauseKey) before it pulls, and Pause and Resume write it with PausePut, so a + // reads the node's pause key (sdkstep.PauseKey) before it pulls, and Pause and Resume write it with PausePut, so a // pause outlives a restart. Load func(key string) ([]byte, error) - // PausePut writes the node's pause key: the module's pauses state (delivery.PausesState), which has no age, so a + // PausePut writes the node's pause key: the module's pauses state (sdkstep.PausesState), which has no age, so a // pause never ends by itself while the steps state drops its old keys. Put when nil. PausePut Put // WatchConfig, when set, follows the module's config value `capacity` (CapacitySetting) as mesh-config resolves it @@ -94,8 +94,8 @@ type Puller struct { // taken, and the asks go to a node that can run them instead of failing here. The module calls Wake when its answer // changes. Nil is always eligible. Eligible func() string - // Has says whether this node has a need an ask names (delivery.AskNeeds, issue 890). An ask needing what it lacks is - // declined: its step ends refused as declined (delivery.StepDeclined), the message is dropped for its asker to put + // Has says whether this node has a need an ask names (sdkstep.AskNeeds, issue 890). An ask needing what it lacks is + // declined: its step ends refused as declined (sdkstep.StepDeclined), the message is dropped for its asker to put // again, and the worker it came from is not looked at again until the next pull's wait, so a node lacking the need // does not take the asker's next copy at once. Nil has nothing. Has func(need string) bool @@ -129,7 +129,7 @@ type Puller struct { hoursFault string hasHours bool // judged is the capacity said last (log and capacity.), so a change is said once. - judged delivery.Capacity + judged sdkstep.Capacity hasJudged bool capWritten bool capFault string @@ -166,11 +166,11 @@ func (p *Puller) Resume(by string) error { return p.setPaused(false, by) } func (p *Puller) setPaused(paused bool, by string) error { if p.Load != nil { - key, err := delivery.PauseKey(p.Node) + key, err := sdkstep.PauseKey(p.Node) if err != nil { return err } - raw, err := delivery.EncodePause(delivery.Pause{Paused: paused, By: by, At: now()}) + raw, err := sdkstep.EncodePause(sdkstep.Pause{Paused: paused, By: by, At: now()}) if err != nil { return err } @@ -194,7 +194,7 @@ func (p *Puller) restore() error { if p.Load == nil { return nil } - key, err := delivery.PauseKey(p.Node) + key, err := sdkstep.PauseKey(p.Node) if err != nil { return err } @@ -205,7 +205,7 @@ func (p *Puller) restore() error { if raw == nil { return nil } - v, err := delivery.DecodePause(raw) + v, err := sdkstep.DecodePause(raw) if err != nil { return fmt.Errorf("buildstep: the pause of %s: %w", p.Node, err) } @@ -287,7 +287,7 @@ func (p *Puller) CapacityNow() int { // else Capacity), bounded by the node's build slice when CoresPerStep is set (EffectiveCapacity), its quota read now. A // change from what it said last is logged and written to `capacity.` of the pauses state (PausePut), so the // delivery view says it too. -func (p *Puller) CapacityDetail() delivery.Capacity { +func (p *Puller) CapacityDetail() sdkstep.Capacity { p.mu.Lock() watched, has := p.watched, p.hasWatched p.mu.Unlock() @@ -307,9 +307,9 @@ func (p *Puller) CapacityDetail() delivery.Capacity { case p.Capacity != nil: configured = p.Capacity() } - var c delivery.Capacity + var c sdkstep.Capacity if p.CoresPerStep == nil { - c = delivery.Capacity{Configured: configured, Effective: configured, QuotaFrom: "not judged: the module names no cores a step needs", + c = sdkstep.Capacity{Configured: configured, Effective: configured, QuotaFrom: "not judged: the module names no cores a step needs", Why: fmt.Sprintf("its configured capacity %d", configured)} } else { quota := p.Quota @@ -375,7 +375,7 @@ func (p *Puller) untilEdge() (time.Duration, bool) { // sayCapacity logs c when it differs from what was said last, and writes it to the node's capacity key until a write // lands (a failed write is tried again at the next judgement, its error said once). -func (p *Puller) sayCapacity(c delivery.Capacity) { +func (p *Puller) sayCapacity(c sdkstep.Capacity) { p.mu.Lock() same := p.hasJudged && p.judged.Effective == c.Effective && p.judged.Configured == c.Configured && p.judged.ByCPU == c.ByCPU && p.judged.CoresPerStep == c.CoresPerStep && p.judged.QuotaCores == c.QuotaCores && p.judged.OutsideHours == c.OutsideHours && @@ -392,12 +392,12 @@ func (p *Puller) sayCapacity(c delivery.Capacity) { if !write { return } - key, err := delivery.CapacityKey(p.Node) + key, err := sdkstep.CapacityKey(p.Node) if err != nil { return } c.At = now() - raw, err := delivery.EncodeCapacity(c) + raw, err := sdkstep.EncodeCapacity(c) if err == nil { _, err = p.PausePut(key, json.RawMessage(raw)) } @@ -493,7 +493,7 @@ func (p *Puller) next(ctx context.Context, wait time.Duration) (*Pulled, error) p.mu.Lock() declined := p.declined p.mu.Unlock() - for _, kind := range delivery.AskKinds { + for _, kind := range sdkstep.AskKinds { if declined[kind] { continue } @@ -502,24 +502,24 @@ func (p *Puller) next(ctx context.Context, wait time.Duration) (*Pulled, error) return withKind(msg, kind), err } } - msg, err := p.Source.Next(ctx, delivery.AskDelivery, wait) + msg, err := p.Source.Next(ctx, sdkstep.AskDelivery, wait) p.mu.Lock() p.declined = nil p.mu.Unlock() - return withKind(msg, delivery.AskDelivery), err + return withKind(msg, sdkstep.AskDelivery), err } // decline ends the step of an ask this node lacks a need of, refused as declined with what it lacks, and drops the // message: the asker puts the ask again under a new id (issue 890). A message whose decline cannot be written is handed // back, as one whose start cannot be. -func (p *Puller) decline(msg *Pulled, ask delivery.StepAsk, lacks []string) { - step, key, err := begin(p.Put, p.Node, ask.Kind+" ask", ask.ID, sdkstep.AskWhat(ask)) +func (p *Puller) decline(msg *Pulled, ask sdkstep.StepAsk, lacks []string) { + step, key, err := begin(p.Put, p.Node, ask.Kind+" ask", ask.ID, buildrun.AskWhat(askRead(ask))) if err == nil { why := Refuse("the ask %s needs %s, and %s lacks %s: it is left to a node that has it", ask.ID, strings.Join(ask.Needs, ", "), p.Node, strings.Join(lacks, ", ")) step.Ended = now().UTC() - step.State, step.Reason = delivery.StepRefused, why.Error() - step.Result, err = json.Marshal(delivery.StepDeclined{Declined: true, Needs: ask.Needs, Lacks: lacks}) + step.State, step.Reason = sdkstep.StepRefused, why.Error() + step.Result, err = json.Marshal(sdkstep.StepDeclined{Declined: true, Needs: ask.Needs, Lacks: lacks}) if err == nil { err = writeEnd(p.Put, key, step) } @@ -542,10 +542,10 @@ func (p *Puller) decline(msg *Pulled, ask delivery.StepAsk, lacks []string) { // declineRun ends the step of an ask that began and then found this node should not run it (Declining), as decline does // before it begins: refused as declined, the message dropped for its asker to put again, and the worker it came from left // alone until the next pull's wait. -func (p *Puller) declineRun(msg *Pulled, ask delivery.StepAsk, key string, step delivery.Step, d *Declining) { - result, _ := json.Marshal(delivery.StepDeclined{Declined: true, Needs: ask.Needs, Lacks: d.Lacks}) +func (p *Puller) declineRun(msg *Pulled, ask sdkstep.StepAsk, key string, step sdkstep.Step, d *Declining) { + result, _ := json.Marshal(sdkstep.StepDeclined{Declined: true, Needs: ask.Needs, Lacks: d.Lacks}) step.Ended = now().UTC() - step.State, step.Reason, step.Result = delivery.StepRefused, d.Text, result + step.State, step.Reason, step.Result = sdkstep.StepRefused, d.Text, result if err := writeEnd(p.Put, key, step); err != nil { p.logf("the ask %s cannot be declined on %s, so it is given back: %v", ask.ID, p.Node, err) _ = msg.Nak() @@ -570,14 +570,14 @@ func (p *Puller) refuseUnread(msg *Pulled, why error) { Kind string `json:"kind"` } if json.Unmarshal(msg.Data, &probe) == nil && probe.ID != "" { - if key, kerr := delivery.AskStepKey(p.Node, probe.ID); kerr == nil { + if key, kerr := sdkstep.AskStepKey(p.Node, probe.ID); kerr == nil { tool := "ask" if probe.Kind != "" { tool = probe.Kind + " ask" } at := now().UTC() - step := delivery.Step{ID: probe.ID, Tool: tool, Node: p.Node, State: delivery.StepRefused, Started: at, Ended: at, - Reason: fmt.Sprintf("this build module on %s cannot read the ask: %v", p.Node, why), Owner: sdkstep.Owner} + step := sdkstep.Step{ID: probe.ID, Tool: tool, Node: p.Node, State: sdkstep.StepRefused, Started: at, Ended: at, + Reason: fmt.Sprintf("this build module on %s cannot read the ask: %v", p.Node, why), Owner: buildrun.Owner} if werr := writeEnd(p.Put, key, step); werr != nil { p.logf("the unread ask %s cannot be answered on %s: %v", probe.ID, p.Node, werr) } else { @@ -601,7 +601,7 @@ func withKind(msg *Pulled, kind string) *Pulled { // take starts one ask: it decodes it, refuses one that is not of this seat, and runs it as a step under its own id. func (p *Puller) take(ctx context.Context, msg *Pulled) { kind := msg.Kind - ask, err := delivery.DecodeStepAsk(msg.Data) + ask, err := sdkstep.DecodeStepAsk(msg.Data) if err == nil && (ask.Language != p.Language || (kind != "" && ask.Kind != kind)) { err = fmt.Errorf("the ask %s is a %s ask of %s, pulled from the %s %s worker", ask.ID, ask.Kind, ask.Language, p.Language, kind) } @@ -612,17 +612,17 @@ func (p *Puller) take(ctx context.Context, msg *Pulled) { p.refuseUnread(msg, err) return } - if lacks := delivery.Lacks(ask.Needs, p.Has); len(lacks) > 0 { + if lacks := sdkstep.Lacks(ask.Needs, p.Has); len(lacks) > 0 { p.decline(msg, ask, lacks) return } - step, key, err := begin(p.Put, p.Node, ask.Kind+" ask", ask.ID, sdkstep.AskWhat(ask)) + step, key, err := begin(p.Put, p.Node, ask.Kind+" ask", ask.ID, buildrun.AskWhat(askRead(ask))) if err != nil { p.logf("the ask %s cannot start on %s: %v", ask.ID, p.Node, err) _ = msg.Nak() return } - if msg.Delivered > delivery.AskRuns { + if msg.Delivered > sdkstep.AskRuns { // Run AskRuns times and never ended: each run took its node down or died with it. It is run no more, and said // (issue 1907): the asker's key fails with why. why := fmt.Errorf("the ask was taken %d times and no run of it ended: each took its build node down or died with it, so it is run no more (poison)", msg.Delivered-1) @@ -646,7 +646,7 @@ func (p *Puller) take(ctx context.Context, msg *Pulled) { go p.run(ctx, msg, ask, key, step) } -func (p *Puller) run(ctx context.Context, msg *Pulled, ask delivery.StepAsk, key string, step delivery.Step) { +func (p *Puller) run(ctx context.Context, msg *Pulled, ask sdkstep.StepAsk, key string, step sdkstep.Step) { defer func() { p.mu.Lock() p.inHand-- @@ -828,3 +828,10 @@ func (p *Puller) capacityFrom(c stdio.ProvisionChange) (int, string) { } return n, "" } + +// askRead is the part of an ask that buildrun.AskWhat reads, taken from the ask's own layout by the one conversion the +// neutral form has (sdkstep.AskAs); an ask that does not convert reads as empty and is described by its kind alone. +func askRead(ask sdkstep.StepAsk) sdkstep.Ask { + out, _ := sdkstep.AskAs[sdkstep.Ask](ask) + return out +} diff --git a/modules/mesh-build-go/buildstep/pull_bus.go b/modules/mesh-build-go/buildstep/pull_bus.go index 99380838e..9f2a8f12a 100644 --- a/modules/mesh-build-go/buildstep/pull_bus.go +++ b/modules/mesh-build-go/buildstep/pull_bus.go @@ -6,13 +6,12 @@ import ( "sync" "time" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "github.com/nats-io/nats.go/jetstream" - - "git.novox.be/novox/mesh-sdk/go/delivery" ) // JetStreamAsks is a build seat's workers on the bus: the shared pull consumer of each kind on the seat's stream -// (delivery.AskStream, AskWorker), which the broker makes when the seat is declared; a holder only binds them. +// (sdkstep.AskStream, AskWorker), which the broker makes when the seat is declared; a holder only binds them. type JetStreamAsks struct { JS jetstream.JetStream Language string @@ -27,11 +26,11 @@ func (s *JetStreamAsks) consumer(ctx context.Context, kind string) (jetstream.Co if c, ok := s.cons[kind]; ok { return c, nil } - stream, err := delivery.AskStream(s.Language) + stream, err := sdkstep.AskStream(s.Language) if err != nil { return nil, err } - worker, err := delivery.AskWorker(s.Language, kind) + worker, err := sdkstep.AskWorker(s.Language, kind) if err != nil { return nil, err } @@ -81,18 +80,18 @@ func (s *JetStreamAsks) Next(ctx context.Context, kind string, wait time.Duratio // modules' integration tests start one). On the mesh the broker declares them from the seat, as it declares every // seat's stream (mesh-controller internal/broker SeatStreams); a holder never does. func DeclareAskWorkers(ctx context.Context, js jetstream.JetStream, language string, ackWait time.Duration) error { - stream, err := delivery.AskStream(language) + stream, err := sdkstep.AskStream(language) if err != nil { return err } - seat, _ := delivery.BuildSeat(language) + seat, _ := sdkstep.BuildSeat(language) if _, err := js.CreateOrUpdateStream(ctx, jetstream.StreamConfig{Name: stream, Subjects: []string{"mesh.seat." + seat + ".accept.>"}, Retention: jetstream.WorkQueuePolicy}); err != nil { return fmt.Errorf("the stream %s: %w", stream, err) } - for _, kind := range delivery.AskKinds { - worker, _ := delivery.AskWorker(language, kind) - subject, _ := delivery.AskSubject(language, kind) + for _, kind := range sdkstep.AskKinds { + worker, _ := sdkstep.AskWorker(language, kind) + subject, _ := sdkstep.AskSubject(language, kind) if _, err := js.CreateOrUpdateConsumer(ctx, stream, jetstream.ConsumerConfig{Durable: worker, FilterSubject: subject, AckPolicy: jetstream.AckExplicitPolicy, AckWait: ackWait, MaxDeliver: 5}); err != nil { return fmt.Errorf("the worker %s: %w", worker, err) diff --git a/modules/mesh-build-go/buildstep/pull_fake.go b/modules/mesh-build-go/buildstep/pull_fake.go index 1e10e4afd..ddefb2338 100644 --- a/modules/mesh-build-go/buildstep/pull_fake.go +++ b/modules/mesh-build-go/buildstep/pull_fake.go @@ -5,7 +5,7 @@ import ( "sync" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // FakeAsks is a test double of a seat's workers for the build modules' unit tests: Publish puts an ask on a kind's @@ -38,8 +38,8 @@ func (f *FakeAsks) Publish(kind string, data []byte) { } // PublishAsk encodes a and puts it on its kind's worker. -func (f *FakeAsks) PublishAsk(a delivery.StepAsk) error { - raw, err := delivery.EncodeStepAsk(a) +func (f *FakeAsks) PublishAsk(a sdkstep.StepAsk) error { + raw, err := sdkstep.EncodeStepAsk(a) if err != nil { return err } @@ -93,7 +93,7 @@ func (f *FakeAsks) Next(ctx context.Context, kind string, wait time.Duration) (* func (f *FakeAsks) pulled(data []byte) *Pulled { id := string(data) - if a, err := delivery.DecodeStepAsk(data); err == nil { + if a, err := sdkstep.DecodeStepAsk(data); err == nil { id = a.ID } fate := func(list *[]string) func() error { diff --git a/modules/mesh-build-go/buildstep/pull_integration_test.go b/modules/mesh-build-go/buildstep/pull_integration_test.go index c210e4bcd..5fee7d300 100644 --- a/modules/mesh-build-go/buildstep/pull_integration_test.go +++ b/modules/mesh-build-go/buildstep/pull_integration_test.go @@ -12,7 +12,7 @@ import ( "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/testbus" ) @@ -27,19 +27,19 @@ func askBus(t *testing.T, ackWait time.Duration) jetstream.JetStream { if err != nil { t.Fatal(err) } - if err := DeclareAskWorkers(context.Background(), js, delivery.AskGo, ackWait); err != nil { + if err := DeclareAskWorkers(context.Background(), js, sdkstep.AskGo, ackWait); err != nil { t.Fatal(err) } return js } -func publishAsk(t *testing.T, js jetstream.JetStream, a delivery.StepAsk) { +func publishAsk(t *testing.T, js jetstream.JetStream, a sdkstep.StepAsk) { t.Helper() - raw, err := delivery.EncodeStepAsk(a) + raw, err := sdkstep.EncodeStepAsk(a) if err != nil { t.Fatal(err) } - subject, _ := delivery.AskSubject(a.Language, a.Kind) + subject, _ := sdkstep.AskSubject(a.Language, a.Kind) if _, err := js.Publish(context.Background(), subject, raw); err != nil { t.Fatal(err) } @@ -49,7 +49,7 @@ func publishAsk(t *testing.T, js jetstream.JetStream, a delivery.StepAsk) { // with capacity 2 and three asks two run, the third waits in the stream and starts when one ends. func TestPullOrderAndCapacityOnTheBus(t *testing.T) { js := askBus(t, 30*time.Second) - check, first, second := testAsk(delivery.AskCheck), testAsk(delivery.AskDelivery), testAsk(delivery.AskDelivery) + check, first, second := testAsk(sdkstep.AskCheck), testAsk(sdkstep.AskDelivery), testAsk(sdkstep.AskDelivery) publishAsk(t, js, check) publishAsk(t, js, first) publishAsk(t, js, second) @@ -57,9 +57,9 @@ func TestPullOrderAndCapacityOnTheBus(t *testing.T) { var mu sync.Mutex var order []string st := newSteps() - p := &Puller{Source: &JetStreamAsks{JS: js, Language: delivery.AskGo}, Language: delivery.AskGo, Node: "shanks", Put: st.put, + p := &Puller{Source: &JetStreamAsks{JS: js, Language: sdkstep.AskGo}, Language: sdkstep.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 2 }, PullWait: 200 * time.Millisecond, - Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { + Run: func(ctx context.Context, a sdkstep.StepAsk) (any, error) { mu.Lock() order = append(order, a.ID) mu.Unlock() @@ -80,9 +80,9 @@ func TestPullOrderAndCapacityOnTheBus(t *testing.T) { waitFor(t, "the check taken", func() bool { mu.Lock(); defer mu.Unlock(); return len(order) == 3 }) close(release) waitFor(t, "every step ended", func() bool { - for _, a := range []delivery.StepAsk{check, first, second} { - key, _ := delivery.AskStepKey("shanks", a.ID) - if s, ok := st.get(key); !ok || s.State != delivery.StepPassed { + for _, a := range []sdkstep.StepAsk{check, first, second} { + key, _ := sdkstep.AskStepKey("shanks", a.ID) + if s, ok := st.get(key); !ok || s.State != sdkstep.StepPassed { return false } } @@ -102,13 +102,13 @@ func TestPullOrderAndCapacityOnTheBus(t *testing.T) { // whose step key names the reference. func TestPullRedeliversAKilledPullersAsk(t *testing.T) { js := askBus(t, 2*time.Second) - a := testAsk(delivery.AskDelivery) + a := testAsk(sdkstep.AskDelivery) publishAsk(t, js, a) st := newSteps() taken := make(chan struct{}) - dying := &Puller{Source: &JetStreamAsks{JS: js, Language: delivery.AskGo}, Language: delivery.AskGo, Node: "ace", Put: st.put, + dying := &Puller{Source: &JetStreamAsks{JS: js, Language: sdkstep.AskGo}, Language: sdkstep.AskGo, Node: "ace", Put: st.put, Capacity: func() int { return 1 }, PullWait: 200 * time.Millisecond, Heartbeat: time.Hour, - Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { + Run: func(ctx context.Context, a sdkstep.StepAsk) (any, error) { close(taken) <-ctx.Done() return nil, ctx.Err() @@ -117,18 +117,18 @@ func TestPullRedeliversAKilledPullersAsk(t *testing.T) { go dying.Pull(kill) <-taken killed() - other := &Puller{Source: &JetStreamAsks{JS: js, Language: delivery.AskGo}, Language: delivery.AskGo, Node: "shanks", Put: st.put, + other := &Puller{Source: &JetStreamAsks{JS: js, Language: sdkstep.AskGo}, Language: sdkstep.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 1 }, PullWait: 200 * time.Millisecond, - Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { - return delivery.BuildAnswer{Artifacts: []delivery.AskBuilt{{Artifact: "bundle", Digest: "sha256:00", Reference: "artifact-store://x"}}}, nil + Run: func(ctx context.Context, a sdkstep.StepAsk) (any, error) { + return sdkstep.BuildAnswer{Artifacts: []sdkstep.AskBuilt{{Artifact: "bundle", Digest: "sha256:00", Reference: "artifact-store://x"}}}, nil }} ctx, cancel := context.WithCancel(context.Background()) defer cancel() go other.Pull(ctx) - key, _ := delivery.AskStepKey("shanks", a.ID) + key, _ := sdkstep.AskStepKey("shanks", a.ID) deadline := time.Now().Add(10 * time.Second) for { - if s, ok := st.get(key); ok && s.State == delivery.StepPassed { + if s, ok := st.get(key); ok && s.State == sdkstep.StepPassed { if want := `artifact-store://x`; !contains(string(s.Result), want) { t.Fatalf("the step's result %s names no %s", s.Result, want) } diff --git a/modules/mesh-build-go/buildstep/pull_seat.go b/modules/mesh-build-go/buildstep/pull_seat.go index 1a8680fbb..9441618b7 100644 --- a/modules/mesh-build-go/buildstep/pull_seat.go +++ b/modules/mesh-build-go/buildstep/pull_seat.go @@ -4,8 +4,8 @@ import ( "context" "time" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/delivery" ) // SeatAsks is a build seat's workers as a tools bundle reaches them: through its runtime's seat pull and settle @@ -27,7 +27,7 @@ func (s SeatAsks) Next(ctx context.Context, kind string, wait time.Duration) (*P if settle == nil { settle = stdio.SeatSettle } - worker, err := delivery.AskWorker(s.Language, kind) + worker, err := sdkstep.AskWorker(s.Language, kind) if err != nil { return nil, err } diff --git a/modules/mesh-build-go/buildstep/pull_seat_test.go b/modules/mesh-build-go/buildstep/pull_seat_test.go index 7d707822b..e72422645 100644 --- a/modules/mesh-build-go/buildstep/pull_seat_test.go +++ b/modules/mesh-build-go/buildstep/pull_seat_test.go @@ -5,15 +5,15 @@ import ( "testing" "time" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/delivery" ) // 1887/A11: a bundle's source pulls the kind's worker by its seat's name and settles each ask by the runtime's id. func TestSeatAsksPullsTheKindsWorkerAndSettlesByID(t *testing.T) { var pulled []string var settled []string - src := SeatAsks{Language: delivery.AskGo, + src := SeatAsks{Language: sdkstep.AskGo, Pull: func(worker string, wait time.Duration) (*stdio.Pulled, error) { pulled = append(pulled, worker) if worker != "SEAT_NODE_BUILD_GO_CHECK_worker" { @@ -22,10 +22,10 @@ func TestSeatAsksPullsTheKindsWorkerAndSettlesByID(t *testing.T) { return &stdio.Pulled{ID: "p9", Worker: worker, Body: []byte(`{"v":1}`), Delivered: 2}, nil }, Settle: func(id, fate string) error { settled = append(settled, id+" "+fate); return nil }} - if p, err := src.Next(context.Background(), delivery.AskDelivery, 0); err != nil || p != nil { + if p, err := src.Next(context.Background(), sdkstep.AskDelivery, 0); err != nil || p != nil { t.Fatalf("delivery = %+v, %v", p, err) } - p, err := src.Next(context.Background(), delivery.AskCheck, time.Second) + p, err := src.Next(context.Background(), sdkstep.AskCheck, time.Second) if err != nil || p == nil || string(p.Data) != `{"v":1}` || p.Delivered != 2 { t.Fatalf("check = %+v, %v", p, err) } diff --git a/modules/mesh-build-go/buildstep/pull_test.go b/modules/mesh-build-go/buildstep/pull_test.go index 9a33b5a4c..b538dccba 100644 --- a/modules/mesh-build-go/buildstep/pull_test.go +++ b/modules/mesh-build-go/buildstep/pull_test.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" "os" "path/filepath" "strings" @@ -14,21 +13,22 @@ import ( "testing" "time" + buildrun "git.novox.be/novox/mesh-delivery/sdk/buildrun" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/delivery" ) // steps records a steps state's puts: the last value of each key. type steps struct { mu sync.Mutex - keys map[string]delivery.Step + keys map[string]sdkstep.Step } -func newSteps() *steps { return &steps{keys: map[string]delivery.Step{}} } +func newSteps() *steps { return &steps{keys: map[string]sdkstep.Step{}} } func (s *steps) put(key string, value any) (uint64, error) { raw, _ := value.(json.RawMessage) - st, err := delivery.DecodeStep(raw) + st, err := sdkstep.DecodeStep(raw) if err != nil { return 0, err } @@ -38,28 +38,28 @@ func (s *steps) put(key string, value any) (uint64, error) { return 1, nil } -func (s *steps) get(key string) (delivery.Step, bool) { +func (s *steps) get(key string) (sdkstep.Step, bool) { s.mu.Lock() defer s.mu.Unlock() st, ok := s.keys[key] return st, ok } -func testAsk(kind string) delivery.StepAsk { +func testAsk(kind string) sdkstep.StepAsk { id := sdkstep.NewID() - a := delivery.StepAsk{ID: id, Kind: kind, Language: delivery.AskGo, Asker: "mesh-delivery-builder", At: time.Now()} - fetch := map[string]any{"repository": "novox/x", "commit": "0123456789abcdef0123456789abcdef01234567"} - if kind == delivery.AskDelivery { + a := sdkstep.StepAsk{ID: id, Kind: kind, Language: sdkstep.AskGo, Asker: "mesh-delivery-builder", At: time.Now()} + fetch := sdkstep.Fetch{Repository: "novox/x", Commit: "0123456789abcdef0123456789abcdef01234567"} + if kind == sdkstep.AskDelivery { b := buildJobFixture - b.Fetch = fetch + b.Fetch = &fetch a.Build = &b } else { - a.Check = &delivery.CheckJob{Fetch: fetch, Levels: []string{delivery.LevelUnit}} + a.Check = &sdkstep.CheckJob{Fetch: fetch, Levels: []string{sdkstep.LevelUnit}} } return a } -var buildJobFixture = delivery.BuildJob{Module: "m", Path: ".", Artifacts: []delivery.AskArtifact{{Artifact: "bundle"}}} +var buildJobFixture = sdkstep.BuildJob{Module: "m", Path: ".", Artifacts: []sdkstep.AskArtifact{{Artifact: "bundle"}}} func waitFor(t *testing.T, what string, cond func() bool) { t.Helper() @@ -75,16 +75,16 @@ func waitFor(t *testing.T, what string, cond func() bool) { // 1887/A2a: given a check ask published before a delivery ask and one slot, the delivery ask runs first. func TestPullTakesDeliveryBeforeCheck(t *testing.T) { src, st := NewFakeAsks(), newSteps() - check, recheck, deliver := testAsk(delivery.AskCheck), testAsk(delivery.AskRecheck), testAsk(delivery.AskDelivery) - for _, a := range []delivery.StepAsk{recheck, check, deliver} { + check, recheck, deliver := testAsk(sdkstep.AskCheck), testAsk(sdkstep.AskRecheck), testAsk(sdkstep.AskDelivery) + for _, a := range []sdkstep.StepAsk{recheck, check, deliver} { if err := src.PublishAsk(a); err != nil { t.Fatal(err) } } var mu sync.Mutex var order []string - p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 1 }, - PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { + p := &Puller{Source: src, Language: sdkstep.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 1 }, + PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a sdkstep.StepAsk) (any, error) { mu.Lock() order = append(order, a.Kind) mu.Unlock() @@ -96,12 +96,12 @@ func TestPullTakesDeliveryBeforeCheck(t *testing.T) { waitFor(t, "three asks acked", func() bool { a, _, _ := src.Fates(); return len(a) == 3 }) mu.Lock() defer mu.Unlock() - if want := []string{delivery.AskDelivery, delivery.AskCheck, delivery.AskRecheck}; len(order) != 3 || order[0] != want[0] || order[1] != want[1] || order[2] != want[2] { + if want := []string{sdkstep.AskDelivery, sdkstep.AskCheck, sdkstep.AskRecheck}; len(order) != 3 || order[0] != want[0] || order[1] != want[1] || order[2] != want[2] { t.Fatalf("order = %v, want %v", order, want) } - key, _ := delivery.AskStepKey("shanks", deliver.ID) + key, _ := sdkstep.AskStepKey("shanks", deliver.ID) s, ok := st.get(key) - if !ok || s.State != delivery.StepPassed || string(s.Result) != `{"ran":"`+deliver.ID+`"}` { + if !ok || s.State != sdkstep.StepPassed || string(s.Result) != `{"ran":"`+deliver.ID+`"}` { t.Fatalf("the step %s = %+v (%v)", key, s, ok) } } @@ -112,9 +112,9 @@ func TestPullTakesDeliveryBeforeCheck(t *testing.T) { // its ask id, and its end keeps the last phase. func TestPullHoldsAtMostItsCapacity(t *testing.T) { src, st := NewFakeAsks(), newSteps() - asks := []delivery.StepAsk{} + asks := []sdkstep.StepAsk{} for i := 0; i < 3; i++ { - a := testAsk(delivery.AskDelivery) + a := testAsk(sdkstep.AskDelivery) asks = append(asks, a) _ = src.PublishAsk(a) } @@ -124,7 +124,7 @@ func TestPullHoldsAtMostItsCapacity(t *testing.T) { var said []string capacities := map[string][]byte{} quota := 1200 - p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 3 }, + p := &Puller{Source: src, Language: sdkstep.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 3 }, CoresPerStep: func() float64 { return 6 }, Quota: func() (int, string) { mu.Lock(); defer mu.Unlock(); return quota, "a test's slice" }, PausePut: func(key string, v any) (uint64, error) { @@ -134,7 +134,7 @@ func TestPullHoldsAtMostItsCapacity(t *testing.T) { return 1, nil }, Logf: func(f string, a ...any) { mu.Lock(); said = append(said, fmt.Sprintf(f, a...)); mu.Unlock() }, - PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { + PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a sdkstep.StepAsk) (any, error) { mu.Lock() started++ mu.Unlock() @@ -147,8 +147,8 @@ func TestPullHoldsAtMostItsCapacity(t *testing.T) { go p.Pull(ctx) waitFor(t, "two in hand", func() bool { return p.InHand() == 2 }) time.Sleep(30 * time.Millisecond) // three pull waits: the third must still wait - if p.InHand() != 2 || src.Waiting(delivery.AskDelivery) != 1 { - t.Fatalf("in hand %d, waiting %d; want 2 and 1", p.InHand(), src.Waiting(delivery.AskDelivery)) + if p.InHand() != 2 || src.Waiting(sdkstep.AskDelivery) != 1 { + t.Fatalf("in hand %d, waiting %d; want 2 and 1", p.InHand(), src.Waiting(sdkstep.AskDelivery)) } c := p.CapacityDetail() if c.Effective != 2 || c.Configured != 3 || c.ByCPU != 2 || c.QuotaCores != 12 || !strings.Contains(c.Why, "bounded by the CPU") { @@ -163,23 +163,23 @@ func TestPullHoldsAtMostItsCapacity(t *testing.T) { } } mu.Unlock() - if got, err := delivery.DecodeCapacity(raw); err != nil || got.Effective != 2 || capLines != 1 { + if got, err := sdkstep.DecodeCapacity(raw); err != nil || got.Effective != 2 || capLines != 1 { t.Fatalf("capacity.shanks = %s (%v), said %d times; want effective 2, said once", raw, err, capLines) } waitFor(t, "a phase on the step key", func() bool { - key, _ := delivery.AskStepKey("shanks", p.Running()[0].ID) + key, _ := sdkstep.AskStepKey("shanks", p.Running()[0].ID) s, _ := st.get(key) - return s.State == delivery.StepRunning && s.Phase == "unit: tests of 3 packages" && !s.PhaseAt.IsZero() && s.Owner == sdkstep.Owner // 1890/AC5 + return s.State == sdkstep.StepRunning && s.Phase == "unit: tests of 3 packages" && !s.PhaseAt.IsZero() && s.Owner == buildrun.Owner // 1890/AC5 }) release <- struct{}{} - waitFor(t, "the third taken", func() bool { return src.Waiting(delivery.AskDelivery) == 0 }) + waitFor(t, "the third taken", func() bool { return src.Waiting(sdkstep.AskDelivery) == 0 }) // Taken from the source comes a moment before its run starts: wait for the start, not only the take. waitFor(t, "the third started", func() bool { mu.Lock(); defer mu.Unlock(); return started == 3 }) close(release) waitFor(t, "all acked", func() bool { a, _, _ := src.Fates(); return len(a) == 3 }) for _, a := range asks { - key, _ := delivery.AskStepKey("shanks", a.ID) - if s, _ := st.get(key); s.State != delivery.StepPassed || s.Phase != "unit: tests of 3 packages" || s.Owner != sdkstep.Owner { + key, _ := sdkstep.AskStepKey("shanks", a.ID) + if s, _ := st.get(key); s.State != sdkstep.StepPassed || s.Phase != "unit: tests of 3 packages" || s.Owner != buildrun.Owner { t.Errorf("the end of %s = %+v, want passed in its last phase", a.ID, s) } } @@ -200,18 +200,18 @@ func TestPullHoldsAtMostItsCapacity(t *testing.T) { // dropped for the asker to put again), and runs on a node that has it. func TestPullDropsWhatIsNoAsk(t *testing.T) { src, st := NewFakeAsks(), newSteps() - src.Publish(delivery.AskDelivery, []byte(`{"v":1}`)) - other := testAsk(delivery.AskDelivery) - other.Language = delivery.AskNode + src.Publish(sdkstep.AskDelivery, []byte(`{"v":1}`)) + other := testAsk(sdkstep.AskDelivery) + other.Language = sdkstep.AskNode _ = src.PublishAsk(other) - placed := testAsk(delivery.AskCheck) - placed.Needs = []string{delivery.NeedNodeBuildNode} + placed := testAsk(sdkstep.AskCheck) + placed.Needs = []string{sdkstep.NeedNodeBuildNode} if err := src.PublishAsk(placed); err != nil { t.Fatal(err) } ran := false - p := &Puller{Source: src, Language: delivery.AskGo, Node: "ace", Put: st.put, Capacity: func() int { return 1 }, - PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { ran = true; return nil, nil }} + p := &Puller{Source: src, Language: sdkstep.AskGo, Node: "ace", Put: st.put, Capacity: func() int { return 1 }, + PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a sdkstep.StepAsk) (any, error) { ran = true; return nil, nil }} ctx, cancel := context.WithCancel(context.Background()) defer cancel() go p.Pull(ctx) @@ -219,31 +219,31 @@ func TestPullDropsWhatIsNoAsk(t *testing.T) { if ran { t.Fatal("a wrong or a declined ask ran") } - key, _ := delivery.AskStepKey("ace", placed.ID) + key, _ := sdkstep.AskStepKey("ace", placed.ID) s, _ := st.get(key) - if d, ok := delivery.DeclinedOf(s); !ok || len(d.Lacks) != 1 || d.Lacks[0] != delivery.NeedNodeBuildNode || !strings.Contains(s.Reason, "lacks node-build-node") { + if d, ok := sdkstep.DeclinedOf(s); !ok || len(d.Lacks) != 1 || d.Lacks[0] != sdkstep.NeedNodeBuildNode || !strings.Contains(s.Reason, "lacks node-build-node") { t.Fatalf("the declined step = %+v", s) } has, hasSteps := NewFakeAsks(), newSteps() _ = has.PublishAsk(placed) - q := &Puller{Source: has, Language: delivery.AskGo, Node: "g14", Put: hasSteps.put, Capacity: func() int { return 1 }, - PullWait: 10 * time.Millisecond, Has: func(n string) bool { return n == delivery.NeedNodeBuildNode }, - Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }} + q := &Puller{Source: has, Language: sdkstep.AskGo, Node: "g14", Put: hasSteps.put, Capacity: func() int { return 1 }, + PullWait: 10 * time.Millisecond, Has: func(n string) bool { return n == sdkstep.NeedNodeBuildNode }, + Run: func(ctx context.Context, a sdkstep.StepAsk) (any, error) { return nil, nil }} go q.Pull(ctx) waitFor(t, "the placed ask acked where its need is met", func() bool { a, _, _ := has.Fates(); return len(a) == 1 }) // A run that finds its node is not on the agreed toolchain declines the ask after it began (ADR 0584 clause 2); a node on it builds. sit, sitSteps := NewFakeAsks(), newSteps() - toolchain := testAsk(delivery.AskDelivery) + toolchain := testAsk(sdkstep.AskDelivery) _ = sit.PublishAsk(toolchain) - s2 := &Puller{Source: sit, Language: delivery.AskGo, Node: "ace", Put: sitSteps.put, Capacity: func() int { return 1 }, PullWait: 10 * time.Millisecond, - Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { + s2 := &Puller{Source: sit, Language: sdkstep.AskGo, Node: "ace", Put: sitSteps.put, Capacity: func() int { return 1 }, PullWait: 10 * time.Millisecond, + Run: func(ctx context.Context, a sdkstep.StepAsk) (any, error) { return nil, &Declining{Text: "the ask is for 26.13.0 and this node builds with 26.12.1", Lacks: []string{"the agreed toolchain 26.13.0 (recipe 3)"}} }} go s2.Pull(ctx) waitFor(t, "the ask termed on the node that sits out", func() bool { _, tm, _ := sit.Fates(); return len(tm) == 1 }) - sk, _ := delivery.AskStepKey("ace", toolchain.ID) + sk, _ := sdkstep.AskStepKey("ace", toolchain.ID) ss, _ := sitSteps.get(sk) - if d, ok := delivery.DeclinedOf(ss); !ok || len(d.Lacks) != 1 || !strings.Contains(ss.Reason, "26.12.1") { + if d, ok := sdkstep.DeclinedOf(ss); !ok || len(d.Lacks) != 1 || !strings.Contains(ss.Reason, "26.12.1") { t.Fatalf("the sitting-out step = %+v", ss) } } @@ -278,17 +278,17 @@ func (k *kv) load(key string) ([]byte, error) { func TestPullAtCapacityZeroTakesNothing(t *testing.T) { t.Run("settings file", func(t *testing.T) { src, st := NewFakeAsks(), &kv{} - _ = src.PublishAsk(testAsk(delivery.AskDelivery)) + _ = src.PublishAsk(testAsk(sdkstep.AskDelivery)) var mu sync.Mutex capacity := 0 - p := &Puller{Source: src, Language: delivery.AskGo, Node: "novox", Put: st.put, PullWait: 10 * time.Millisecond, + p := &Puller{Source: src, Language: sdkstep.AskGo, Node: "novox", Put: st.put, PullWait: 10 * time.Millisecond, Capacity: func() int { mu.Lock(); defer mu.Unlock(); return capacity }, - Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }} + Run: func(ctx context.Context, a sdkstep.StepAsk) (any, error) { return nil, nil }} ctx, cancel := context.WithCancel(context.Background()) defer cancel() go p.Pull(ctx) time.Sleep(50 * time.Millisecond) - if src.Waiting(delivery.AskDelivery) != 1 { + if src.Waiting(sdkstep.AskDelivery) != 1 { t.Fatal("a node at capacity 0 took an ask") } mu.Lock() @@ -299,18 +299,18 @@ func TestPullAtCapacityZeroTakesNothing(t *testing.T) { }) t.Run("config watch wakes", func(t *testing.T) { src, st := NewFakeAsks(), newSteps() - _ = src.PublishAsk(testAsk(delivery.AskDelivery)) + _ = src.PublishAsk(testAsk(sdkstep.AskDelivery)) w := &configWatch{value: "0"} - p := &Puller{Source: src, Language: delivery.AskGo, Node: "g14", Put: st.put, PullWait: 10 * time.Millisecond, + p := &Puller{Source: src, Language: sdkstep.AskGo, Node: "g14", Put: st.put, PullWait: 10 * time.Millisecond, Capacity: func() int { return 0 }, // the settings file, not refilled yet WatchConfig: w.watch, - Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }} + Run: func(ctx context.Context, a sdkstep.StepAsk) (any, error) { return nil, nil }} ctx, cancel := context.WithCancel(context.Background()) defer cancel() go p.Pull(ctx) waitFor(t, "the watch of the capacity", func() bool { w.mu.Lock(); defer w.mu.Unlock(); return w.handle != nil }) time.Sleep(30 * time.Millisecond) - if src.Waiting(delivery.AskDelivery) != 1 { + if src.Waiting(sdkstep.AskDelivery) != 1 { t.Fatal("a node at capacity 0 took an ask") } if w.asked != sdkstep.CapacitySetting { @@ -323,7 +323,7 @@ func TestPullAtCapacityZeroTakesNothing(t *testing.T) { // 2026-10-28: a replaced builder held the old "shanks=2,..." in its settings file, its config follow timed out, and // the puller parked at 0 with nothing to wake it when the node-engine refilled the file. src, st := NewFakeAsks(), newSteps() - _ = src.PublishAsk(testAsk(delivery.AskDelivery)) + _ = src.PublishAsk(testAsk(sdkstep.AskDelivery)) file := filepath.Join(t.TempDir(), "settings.json") if err := os.WriteFile(file, []byte(`{"capacity":"shanks=2,ace=2"}`), 0o600); err != nil { t.Fatal(err) @@ -347,14 +347,14 @@ func TestPullAtCapacityZeroTakesNothing(t *testing.T) { parked.Add(1) } } - p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, PullWait: 10 * time.Millisecond, + p := &Puller{Source: src, Language: sdkstep.AskGo, Node: "shanks", Put: st.put, PullWait: 10 * time.Millisecond, SettingsFile: file, WatchConfig: watch, Logf: logf, watching: func() { armed <- struct{}{} }, - Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }} + Run: func(ctx context.Context, a sdkstep.StepAsk) (any, error) { return nil, nil }} ctx, cancel := context.WithCancel(context.Background()) defer cancel() go p.Pull(ctx) waitFor(t, "the parked puller to say why", func() bool { why, fault := p.CapacityNote(); return fault && why != "" }) - if src.Waiting(delivery.AskDelivery) != 1 { + if src.Waiting(sdkstep.AskDelivery) != 1 { t.Fatal("a node whose capacity cannot be read took an ask") } <-armed // the file watch is in place @@ -377,7 +377,7 @@ func TestPullAtCapacityZeroTakesNothing(t *testing.T) { } <-armed // armed again waitFor(t, "the puller parked with no settings file", func() bool { return parks() > before }) - _ = src.PublishAsk(testAsk(delivery.AskDelivery)) + _ = src.PublishAsk(testAsk(sdkstep.AskDelivery)) if err := os.WriteFile(file, []byte(`{"capacity":2}`), 0o600); err != nil { t.Fatal(err) } @@ -397,9 +397,9 @@ func TestPullAtCapacityZeroTakesNothing(t *testing.T) { }, true}, } { t.Run(c.name, func(t *testing.T) { - p := &Puller{Source: NewFakeAsks(), Language: delivery.AskGo, Node: "g14", Put: newSteps().put, + p := &Puller{Source: NewFakeAsks(), Language: sdkstep.AskGo, Node: "g14", Put: newSteps().put, Capacity: func() int { return 1 }, WatchConfig: c.watch, - Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }} + Run: func(ctx context.Context, a sdkstep.StepAsk) (any, error) { return nil, nil }} p.watchCapacity() if p.room() != c.room { t.Fatalf("room = %v, want %v", p.room(), c.room) @@ -412,30 +412,30 @@ func TestPullAtCapacityZeroTakesNothing(t *testing.T) { // stays paused until resumed. func TestPullPauseOutlivesARestart(t *testing.T) { src, st, steps := NewFakeAsks(), &kv{}, &kv{} - run := func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil } - p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: steps.put, PausePut: st.put, Load: st.load, + run := func(ctx context.Context, a sdkstep.StepAsk) (any, error) { return nil, nil } + p := &Puller{Source: src, Language: sdkstep.AskGo, Node: "shanks", Put: steps.put, PausePut: st.put, Load: st.load, PullWait: 10 * time.Millisecond, Capacity: func() int { return 2 }, Run: run} if err := p.Pause("the operator"); err != nil { t.Fatal(err) } - key, _ := delivery.PauseKey("shanks") + key, _ := sdkstep.PauseKey("shanks") raw, _ := st.load(key) - if v, err := delivery.DecodePause(raw); err != nil || !v.Paused || v.By != "the operator" { + if v, err := sdkstep.DecodePause(raw); err != nil || !v.Paused || v.By != "the operator" { t.Fatalf("the pause key holds %s (%v)", raw, err) } if raw, _ := steps.load(key); raw != nil { t.Fatalf("the pause was written in the steps state, whose keys age out: %s", raw) } // The restart: a new puller on the same state. - again := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: steps.put, PausePut: st.put, Load: st.load, + again := &Puller{Source: src, Language: sdkstep.AskGo, Node: "shanks", Put: steps.put, PausePut: st.put, Load: st.load, PullWait: 10 * time.Millisecond, Capacity: func() int { return 2 }, Run: run} - _ = src.PublishAsk(testAsk(delivery.AskDelivery)) + _ = src.PublishAsk(testAsk(sdkstep.AskDelivery)) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go again.Pull(ctx) time.Sleep(50 * time.Millisecond) - if !again.Paused() || src.Waiting(delivery.AskDelivery) != 1 { - t.Fatalf("restarted: paused %v, waiting %d; want paused and 1", again.Paused(), src.Waiting(delivery.AskDelivery)) + if !again.Paused() || src.Waiting(sdkstep.AskDelivery) != 1 { + t.Fatalf("restarted: paused %v, waiting %d; want paused and 1", again.Paused(), src.Waiting(sdkstep.AskDelivery)) } if err := again.Resume("the operator"); err != nil { t.Fatal(err) @@ -445,7 +445,7 @@ func TestPullPauseOutlivesARestart(t *testing.T) { t.Run("a pause that cannot be read is said and pulled as not paused", func(t *testing.T) { src := NewFakeAsks() var said atomic.Value - p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: steps.put, PausePut: st.put, + p := &Puller{Source: src, Language: sdkstep.AskGo, Node: "shanks", Put: steps.put, PausePut: st.put, Load: func(string) ([]byte, error) { return nil, errors.New("no runtime yet") }, PullWait: 10 * time.Millisecond, Capacity: func() int { return 2 }, Run: run, Logf: func(f string, a ...any) { @@ -453,7 +453,7 @@ func TestPullPauseOutlivesARestart(t *testing.T) { said.Store(m) } }} - _ = src.PublishAsk(testAsk(delivery.AskDelivery)) + _ = src.PublishAsk(testAsk(sdkstep.AskDelivery)) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go p.Pull(ctx) @@ -468,12 +468,12 @@ func TestPullPauseOutlivesARestart(t *testing.T) { // acknowledges its ask, so the stream gives it to another node. func TestPullHeartbeatAndStop(t *testing.T) { src, st := NewFakeAsks(), newSteps() - a := testAsk(delivery.AskDelivery) + a := testAsk(sdkstep.AskDelivery) _ = src.PublishAsk(a) running := make(chan struct{}) - p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 1 }, + p := &Puller{Source: src, Language: sdkstep.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 1 }, PullWait: 10 * time.Millisecond, Heartbeat: 5 * time.Millisecond, - Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { + Run: func(ctx context.Context, a sdkstep.StepAsk) (any, error) { close(running) <-ctx.Done() return nil, ctx.Err() @@ -490,8 +490,8 @@ func TestPullHeartbeatAndStop(t *testing.T) { if len(acked)+len(termed)+len(naked) != 0 { t.Fatalf("a stopped holder gave its ask a fate: acked %v termed %v naked %v", acked, termed, naked) } - key, _ := delivery.AskStepKey("shanks", a.ID) - if s, ok := st.get(key); !ok || s.State != delivery.StepRunning { + key, _ := sdkstep.AskStepKey("shanks", a.ID) + if s, ok := st.get(key); !ok || s.State != sdkstep.StepRunning { t.Fatalf("the step %s = %+v, want left running", key, s) } } @@ -500,19 +500,19 @@ func TestPullHeartbeatAndStop(t *testing.T) { // asker's key fails, and it is ended on the stream, not offered to another node. func TestAPoisonedAskIsSaidAndRunNoMore(t *testing.T) { src, st := NewFakeAsks(), newSteps() - src.Delivered = delivery.AskRuns + 1 - a := testAsk(delivery.AskDelivery) + src.Delivered = sdkstep.AskRuns + 1 + a := testAsk(sdkstep.AskDelivery) _ = src.PublishAsk(a) ran := false - p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 1 }, - PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { ran = true; return nil, nil }} + p := &Puller{Source: src, Language: sdkstep.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 1 }, + PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a sdkstep.StepAsk) (any, error) { ran = true; return nil, nil }} ctx, cancel := context.WithCancel(context.Background()) defer cancel() go p.Pull(ctx) waitFor(t, "the ask termed", func() bool { _, tm, _ := src.Fates(); return len(tm) == 1 }) - key, _ := delivery.AskStepKey("shanks", a.ID) + key, _ := sdkstep.AskStepKey("shanks", a.ID) s, ok := st.get(key) - if ran || !ok || s.State != delivery.StepFailed || !strings.Contains(s.Reason, "taken 3 times") || !strings.Contains(s.Reason, "poison") { + if ran || !ok || s.State != sdkstep.StepFailed || !strings.Contains(s.Reason, "taken 3 times") || !strings.Contains(s.Reason, "poison") { t.Fatalf("ran %v, step %+v", ran, s) } if acked, _, naked := src.Fates(); len(acked)+len(naked) != 0 { @@ -524,8 +524,8 @@ func TestAPoisonedAskIsSaidAndRunNoMore(t *testing.T) { // (a dropped ask leaves its asker waiting to its bound); one with no id is still dropped. func TestPullAnswersAnAskItCannotRead(t *testing.T) { src, st := NewFakeAsks(), newSteps() - good := testAsk(delivery.AskCheck) - raw, err := delivery.EncodeStepAsk(good) + good := testAsk(sdkstep.AskCheck) + raw, err := sdkstep.EncodeStepAsk(good) if err != nil { t.Fatal(err) } @@ -535,13 +535,13 @@ func TestPullAnswersAnAskItCannotRead(t *testing.T) { m["frobnicate"] = map[string]any{"program": "x"} odd, _ := json.Marshal(m) // The job is not one this reader holds, so the ask carries none it knows: decode refuses it. - if _, err := delivery.DecodeStepAsk(odd); err == nil { + if _, err := sdkstep.DecodeStepAsk(odd); err == nil { t.Fatal("the fixture ask decodes") } - src.Publish(delivery.AskCheck, odd) - src.Publish(delivery.AskCheck, []byte(`{"v":1}`)) - p := &Puller{Source: src, Language: delivery.AskGo, Node: "ace", Put: st.put, Capacity: func() int { return 1 }, - PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { + src.Publish(sdkstep.AskCheck, odd) + src.Publish(sdkstep.AskCheck, []byte(`{"v":1}`)) + p := &Puller{Source: src, Language: sdkstep.AskGo, Node: "ace", Put: st.put, Capacity: func() int { return 1 }, + PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a sdkstep.StepAsk) (any, error) { t.Error("an unread ask ran") return nil, nil }} @@ -549,9 +549,9 @@ func TestPullAnswersAnAskItCannotRead(t *testing.T) { defer cancel() go p.Pull(ctx) waitFor(t, "both termed", func() bool { _, tm, _ := src.Fates(); return len(tm) == 2 }) - key, _ := delivery.AskStepKey("ace", good.ID) + key, _ := sdkstep.AskStepKey("ace", good.ID) s, ok := st.get(key) - if !ok || s.State != delivery.StepRefused || !strings.Contains(s.Reason, "cannot read the ask") || !strings.Contains(s.Reason, "exactly one") { + if !ok || s.State != sdkstep.StepRefused || !strings.Contains(s.Reason, "cannot read the ask") || !strings.Contains(s.Reason, "exactly one") { t.Fatalf("the unread ask's step = %+v (%v)", s, ok) } } diff --git a/modules/mesh-build-go/buildstep/refusal.go b/modules/mesh-build-go/buildstep/refusal.go index 9e4fa738e..909b2e6ea 100644 --- a/modules/mesh-build-go/buildstep/refusal.go +++ b/modules/mesh-build-go/buildstep/refusal.go @@ -17,7 +17,7 @@ func (r *Refusal) Error() string { return r.Text } // Refuse is a Refusal with its text formatted. func Refuse(format string, args ...any) *Refusal { return &Refusal{Text: fmt.Sprintf(format, args...)} } -// Declining is a step a node does not run because another node should: it ends refused as declined (delivery.StepDeclined), +// Declining is a step a node does not run because another node should: it ends refused as declined (sdkstep.StepDeclined), // the ask's message is dropped, and the asker puts the ask again under a new id (issue 890). A build ask naming a // toolchain other than this node's is one (novox/hq ADR 0584 clause 2): a node that is not in the toolchain majority // sits out and never fails a build. diff --git a/modules/mesh-build-go/buildstep/seat_tools_test.go b/modules/mesh-build-go/buildstep/seat_tools_test.go index 471ec3d4f..5d04a5862 100644 --- a/modules/mesh-build-go/buildstep/seat_tools_test.go +++ b/modules/mesh-build-go/buildstep/seat_tools_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // 1887/A9a, ADR 0525 §10: a build seat's verbs are .current, .pause and .resume; current names the ask running, @@ -13,8 +13,8 @@ import ( func TestSeatToolsCurrentPauseResume(t *testing.T) { src, st := NewFakeAsks(), newSteps() release := make(chan struct{}) - p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 1 }, - PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { <-release; return nil, nil }} + p := &Puller{Source: src, Language: sdkstep.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 1 }, + PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a sdkstep.StepAsk) (any, error) { <-release; return nil, nil }} tools := map[string]func(map[string]any) (any, error){} for _, tool := range SeatTools("node-build-go", "Go", p) { tools[tool.Name] = tool.Run @@ -24,7 +24,7 @@ func TestSeatToolsCurrentPauseResume(t *testing.T) { t.Fatalf("no %s", name) } } - a := testAsk(delivery.AskDelivery) + a := testAsk(sdkstep.AskDelivery) _ = src.PublishAsk(a) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -40,13 +40,13 @@ func TestSeatToolsCurrentPauseResume(t *testing.T) { t.Fatalf("pause = %v, %v", out, err) } close(release) - _ = src.PublishAsk(testAsk(delivery.AskDelivery)) + _ = src.PublishAsk(testAsk(sdkstep.AskDelivery)) time.Sleep(50 * time.Millisecond) - if src.Waiting(delivery.AskDelivery) != 1 { + if src.Waiting(sdkstep.AskDelivery) != 1 { t.Fatal("a paused node took an ask") } if _, err := tools["node-build-go.resume"](nil); err != nil { t.Fatal(err) } - waitFor(t, "the ask taken after the resume", func() bool { return src.Waiting(delivery.AskDelivery) == 0 }) + waitFor(t, "the ask taken after the resume", func() bool { return src.Waiting(sdkstep.AskDelivery) == 0 }) } diff --git a/modules/mesh-build-go/buildstep/seatgen/clients.go b/modules/mesh-build-go/buildstep/seatgen/clients.go new file mode 100644 index 000000000..4a56a0d6b --- /dev/null +++ b/modules/mesh-build-go/buildstep/seatgen/clients.go @@ -0,0 +1,214 @@ +package seatgen + +import ( + "encoding/json" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + re "regexp" + "sort" + "strconv" + "strings" + + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// ClientRoot is where a seat's hand-written client packages live (ADR 0625 §4, §5; ADR 0627): one directory per +// client, `go/clients/client`, a Go module of its own (ADR 0625 §3), so the seat set's importers +// never import a client and a client's import of mesh-sdk forms no cycle. Compare leaves the tree to CheckClients. +const ClientRoot = "go/clients" + +// RuntimeModule is mesh-sdk's Go module: the one module beside its own seat's contract a client may import (ADR 0625 +// §3, §4). +const RuntimeModule = "git.novox.be/novox/mesh-sdk/go" + +// ClientGoModule is the Go module of a seat's client, beside its contract's. +func ClientGoModule(root, seat string) string { return root + "/clients/" + pkgName(seat) + "client" } + +// a module's tool address (`.`) or call (`.` written with an underscore). +var toolAddress = re.MustCompile(`^[a-z][a-z0-9-]*\.[a-z0-9]+_[a-z0-9_]+$`) + +// CheckClients is ADR 0627 §3's catalog check of the clients, and ADR 0625 §5's rule for the files beside them: every +// directory under ClientRoot is the client of one seat defined whole, a Go module of its own at ClientGoModule; it +// imports only the standard library, its own seat's contract module, mesh-sdk's runtime and its domain's SDK (never another seat's +// contract or client); and no string in it names a module's subject (`mesh.mod.`), a module's tool, or another seat +// (its name, its subjects, its `seat:` call). Each problem is named once, sorted. +func CheckClients(dir string, m *Module) ([]string, error) { + var out []string + entries, err := os.ReadDir(filepath.Join(dir, ClientRoot)) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + bySeatPkg := map[string]string{} + for name, d := range m.Seats { + if d.Grain == seatdef.Whole { + bySeatPkg[pkgName(name)+"client"] = name + } + } + for _, e := range entries { + at := ClientRoot + "/" + e.Name() + if !e.IsDir() { + out = append(out, at+": a file under the clients' root, in no client") + continue + } + seat, ok := bySeatPkg[e.Name()] + if !ok { + out = append(out, at+": the client of no seat defined whole (a client is `client`)") + continue + } + mod, err := os.ReadFile(filepath.Join(dir, at, "go.mod")) + want := "module " + ClientGoModule(m.GoModule, seat) + if err != nil || strings.SplitN(string(mod), "\n", 2)[0] != want { + out = append(out, at+"/go.mod: not "+strconv.Quote(want)+": a client is a Go module of its own (ADR 0625 §3)") + } + problems, err := checkClient(dir, at, seat, m) + if err != nil { + return nil, err + } + out = append(out, problems...) + } + sort.Strings(out) + return out, nil +} + +func checkClient(dir, at, seat string, m *Module) ([]string, error) { + var out []string + own := SeatGoModule(m.GoModule, seat) + others := map[string]bool{} + for name := range m.Seats { + if name != seat { + others[name] = true + } + } + namesOther := func(s string) bool { + if others[s] { + return true + } + for name := range others { + if strings.HasPrefix(s, "mesh.seat."+name+".") || strings.HasPrefix(s, "seat:"+name+".") { + return true + } + } + return false + } + err := filepath.WalkDir(filepath.Join(dir, at), func(p string, e os.DirEntry, err error) error { + if err != nil || e.IsDir() || !strings.HasSuffix(p, ".go") { + return err + } + r := rel(dir, p) + f, err := parser.ParseFile(token.NewFileSet(), p, nil, parser.SkipObjectResolution) + if err != nil { + return err + } + for _, im := range f.Imports { + path, _ := strconv.Unquote(im.Path.Value) + switch { + case !strings.Contains(strings.SplitN(path, "/", 2)[0], "."): + case path == own: + case m.DomainSDK != "" && (path == m.DomainSDK || strings.HasPrefix(path, m.DomainSDK+"/")): + // the domain's SDK (ADR 0634 §2) + case path == RuntimeModule || strings.HasPrefix(path, RuntimeModule+"/"): + case path == ClientGoModule(m.GoModule, seat) || strings.HasPrefix(path, ClientGoModule(m.GoModule, seat)+"/"): + // the client's own packages: a client may be several packages of its module + case strings.HasPrefix(path, m.GoModule+"/clients/"): + out = append(out, r+": imports "+path+", another seat's client (ADR 0625 §4)") + case path == m.GoModule || strings.HasPrefix(path, m.GoModule+"/"): + out = append(out, r+": imports "+path+", not its own seat's contract (ADR 0625 §4)") + default: + out = append(out, r+": imports "+path+": a client uses only its seat's contract and mesh-sdk's runtime (ADR 0625 §3)") + } + } + ast.Inspect(f, func(n ast.Node) bool { + lit, ok := n.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return true + } + v, err := strconv.Unquote(lit.Value) + if err != nil { + return true + } + switch { + case strings.HasPrefix(v, "mesh.mod."): + out = append(out, fmt.Sprintf("%s: names a module's subject %q (ADR 0625 §4)", r, v)) + case toolAddress.MatchString(v): + out = append(out, fmt.Sprintf("%s: names a module's tool %q (ADR 0625 §4)", r, v)) + case namesOther(v): + out = append(out, fmt.Sprintf("%s: names another seat %q (ADR 0625 §4)", r, v)) + } + return true + }) + return nil + }) + return out, err +} + +// CheckArtifacts is the check of module.json's package artifacts against the tree (ADR 0625 §1, §3), per language. +// Go: each seat defined whole is the artifact `go-` at its module's directory, each client +// present the artifact `go-client` at its directory. npm: each seat defined whole is the artifact +// `ts-` at TSSeatDir. There is no root package of either kind (ADR 0635 §1). No other package artifact of either language. Each problem is named once, +// sorted. +func CheckArtifacts(dir string, m *Module) ([]string, error) { + raw, err := os.ReadFile(filepath.Join(dir, "module.json")) + if err != nil { + return nil, err + } + var mf struct { + Build struct { + Artifacts []struct { + Name string `json:"name"` + Kind string `json:"kind"` + Language string `json:"language"` + Paths []string `json:"paths"` + } `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal(raw, &mf); err != nil { + return nil, fmt.Errorf("module.json: %w", err) + } + want := map[string]map[string]string{"go": {}, "typescript": {}} + for name, d := range m.Seats { + if d.Grain == seatdef.Whole { + want["go"]["go-"+pkgName(name)] = "go/seats/" + pkgName(name) + want["typescript"]["ts-"+pkgName(name)] = TSSeatDir(name) + } + } + if entries, err := os.ReadDir(filepath.Join(dir, ClientRoot)); err == nil { + for _, e := range entries { + if e.IsDir() { + want["go"]["go-"+e.Name()] = ClientRoot + "/" + e.Name() + } + } + } + said := map[string]string{"go": "Go", "typescript": "npm"} + var out []string + seen := map[string]bool{} + for _, a := range mf.Build.Artifacts { + paths, lang := want[a.Language] + if a.Kind != "package" || !lang { + continue + } + seen[a.Language+"/"+a.Name] = true + path, ok := paths[a.Name] + switch { + case !ok: + out = append(out, "module.json: the "+said[a.Language]+" package artifact "+a.Name+" is no seat's module and no client's") + case len(a.Paths) != 1 || a.Paths[0] != path: + out = append(out, fmt.Sprintf("module.json: the artifact %s's paths are %v, not [%s]", a.Name, a.Paths, path)) + } + } + for lang, paths := range want { + for name, path := range paths { + if !seen[lang+"/"+name] { + out = append(out, "module.json: no "+said[lang]+" package artifact "+name+" for "+path) + } + } + } + sort.Strings(out) + return out, nil +} diff --git a/modules/mesh-build-go/buildstep/seatgen/clients_test.go b/modules/mesh-build-go/buildstep/seatgen/clients_test.go new file mode 100644 index 000000000..6f23fa424 --- /dev/null +++ b/modules/mesh-build-go/buildstep/seatgen/clients_test.go @@ -0,0 +1,164 @@ +package seatgen + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// ADR 0627 §3's catalog check of a client, ADR 0625 §5's rule for what is hand-written, and module.json's Go package +// artifacts against the tree (ADR 0625 §1, §3): each row writes one tree and names what the checks must say of it. +func TestAClientUsesOnlyItsSeatAndTheRuntime(t *testing.T) { + m, err := Load("testdata/contracts", "example.org/contracts/go", "@example/contracts") + if err != nil { + t.Fatal(err) + } + m.DomainSDK = "example.org/domain/sdk" + files, err := Generate(m) + if err != nil { + t.Fatal(err) + } + const good = `package aregistryclient + +import ( + "context" + + "example.org/contracts/go/seats/aregistry" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +func Publish(ctx context.Context) string { _ = keys.EscapeName; return "seat:" + aregistry.Seat + ".publish" } +` + const ts = `,{"name":"ts-aregistry","kind":"package","language":"typescript","paths":["ts/seats/aregistry"]}, +{"name":"ts-bregistry","kind":"package","language":"typescript","paths":["ts/seats/bregistry"]}` + artifacts := func(extra string) string { + return `{"build":{"artifacts":[{"name":"go-aregistry","kind":"package","language":"go","paths":["go/seats/aregistry"]}, +{"name":"go-bregistry","kind":"package","language":"go","paths":["go/seats/bregistry"]}` + ts + extra + `]}}` + } + clientArtifact := `,{"name":"go-aregistryclient","kind":"package","language":"go","paths":["go/clients/aregistryclient"]}` + for _, c := range []struct { + name string + tree map[string]string + module string + want []string + }{ + {"no client: the seats alone", nil, artifacts(""), nil}, + {"a client of its own seat over the runtime", map[string]string{ + "go/clients/aregistryclient/go.mod": "module example.org/contracts/go/clients/aregistryclient\n", + "go/clients/aregistryclient/client.go": good, + }, artifacts(clientArtifact), nil}, + {"a client with no artifact, and an artifact of no module", map[string]string{ + "go/clients/aregistryclient/go.mod": "module example.org/contracts/go/clients/aregistryclient\n", + "go/clients/aregistryclient/client.go": good, + }, artifacts(`,{"name":"go-extra","kind":"package","language":"go","paths":["go/extra"]}`), + []string{"module.json: no Go package artifact go-aregistryclient", "module.json: the Go package artifact go-extra is no seat's"}}, + {"a seat's npm package with no artifact, one at another path, and one of no package", nil, + `{"build":{"artifacts":[{"name":"go-aregistry","kind":"package","language":"go","paths":["go/seats/aregistry"]}, +{"name":"go-bregistry","kind":"package","language":"go","paths":["go/seats/bregistry"]}, +{"name":"ts-aregistry","kind":"package","language":"typescript","paths":["ts"]}, +{"name":"ts-onestore","kind":"package","language":"typescript","paths":["ts/seats/onestore"]}]}}`, + []string{"module.json: no npm package artifact ts-bregistry", "module.json: the artifact ts-aregistry's paths are [ts], not [ts/seats/aregistry]", + "module.json: the npm package artifact ts-onestore is no seat's"}}, + {"a client using its own sub-package, which is no other seat's client", map[string]string{ + "go/clients/aregistryclient/go.mod": "module example.org/contracts/go/clients/aregistryclient\n", + "go/clients/aregistryclient/client.go": `package aregistryclient + +import "example.org/contracts/go/clients/aregistryclient/provider" + +var _ = provider.X +`, + }, artifacts(clientArtifact), nil}, + {"a client importing its domain's SDK, which another module's is not", map[string]string{ + "go/clients/aregistryclient/go.mod": "module example.org/contracts/go/clients/aregistryclient\n", + "go/clients/aregistryclient/client.go": `package aregistryclient + +import ( + "example.org/domain/sdk/reference" + "example.org/other/sdk/reference" +) +`, + }, artifacts(clientArtifact), []string{`client.go: imports example.org/other/sdk/reference: a client uses only`}}, + {"a client of no seat defined whole, and one not a module of its own", map[string]string{ + "go/clients/onestoreclient/x.go": "package onestoreclient\n", + "go/clients/aregistryclient/go.mod": "module example.org/contracts/go\n", + }, artifacts(clientArtifact + `,{"name":"go-onestoreclient","kind":"package","language":"go","paths":["go/clients/onestoreclient"]}`), + []string{"go/clients/aregistryclient/go.mod: not", "go/clients/onestoreclient: the client of no seat defined whole"}}, + {"a client using another seat, its client, a module's subject and tool", map[string]string{ + "go/clients/aregistryclient/go.mod": "module example.org/contracts/go/clients/aregistryclient\n", + "go/clients/aregistryclient/client.go": `package aregistryclient + +import ( + "example.org/contracts/go/seats/bregistry" + "example.org/contracts/go/clients/bregistryclient" + "github.com/some/thing" +) + +var a, b, c, d, e = bregistry.Seat, bregistryclient.X, thing.Y, "mesh.mod.holder.event.x", "seat:b-registry.publish" +var f = "records.records_search" +`, + }, artifacts(clientArtifact), []string{ + `client.go: imports example.org/contracts/go/clients/bregistryclient, another seat's client`, + `client.go: imports example.org/contracts/go/seats/bregistry, not its own seat's contract`, + `client.go: imports github.com/some/thing: a client uses only`, + `client.go: names a module's subject "mesh.mod.holder.event.x"`, + `client.go: names a module's tool "records.records_search"`, + `client.go: names another seat "seat:b-registry.publish"`, + }}, + } { + dir := t.TempDir() + write := func(p, s string) { + full := filepath.Join(dir, p) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(s), 0o644); err != nil { + t.Fatal(err) + } + } + for p, b := range files { + write(p, string(b)) + } + for p, s := range c.tree { + write(p, s) + } + write("module.json", c.module) + var got []string + cmp, err := Compare(dir, files) + if err != nil { + t.Fatal(err) + } + got = append(got, cmp...) + for _, check := range []func(string, *Module) ([]string, error){CheckClients, CheckArtifacts} { + more, err := check(dir, m) + if err != nil { + t.Fatalf("%s: %v", c.name, err) + } + got = append(got, more...) + } + if len(got) != len(c.want) { + t.Errorf("%s: said %d problems, want %d:\n%s", c.name, len(got), len(c.want), strings.Join(got, "\n")) + continue + } + for _, w := range c.want { + found := false + for _, g := range got { + found = found || strings.Contains(g, w) + } + if !found { + t.Errorf("%s: no problem says %q:\n%s", c.name, w, strings.Join(got, "\n")) + } + } + } + // A hand-written file outside a client is still out of step (ADR 0625 §5). + dir := t.TempDir() + for p, b := range files { + full := filepath.Join(dir, p) + _ = os.MkdirAll(filepath.Dir(full), 0o755) + _ = os.WriteFile(full, b, 0o644) + } + _ = os.WriteFile(filepath.Join(dir, "go/seats/aregistry/helper.go"), []byte("package aregistry\n"), 0o644) + if out, _ := Compare(dir, files); len(out) != 1 || !strings.Contains(out[0], "helper.go: not written by the generator") { + t.Errorf("a hand-written file beside a contract: %v", out) + } +} diff --git a/modules/mesh-build-go/buildstep/seatgen/gogen.go b/modules/mesh-build-go/buildstep/seatgen/gogen.go new file mode 100644 index 000000000..0c231490d --- /dev/null +++ b/modules/mesh-build-go/buildstep/seatgen/gogen.go @@ -0,0 +1,462 @@ +package seatgen + +import ( + "bytes" + "encoding/json" + "fmt" + "go/format" + "sort" + "strconv" + "strings" + + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +const header = "// Code generated by seatgen from the seat definitions; DO NOT EDIT.\n\n" + +func goFiles(m *Module, out map[string][]byte) error { + // Each seat defined whole is a Go module of its own, so a change of one seat moves only its importers, whose + // fingerprint counts a Go module's go.mod and go.sum (ADR 0625 §1, §2). A seat's module imports nothing: the parts it + // includes are written into it whole. There is no seat list and no root Go module (ADR 0635 §1): the list is read + // from the bus. + for _, name := range m.Order { + d := m.Seats[name] + if d.Grain != seatdef.Whole { + continue + } + src, test, err := goWhole(m, d) + if err != nil { + return fmt.Errorf("seat %s: %w", name, err) + } + dir := "go/seats/" + pkgName(name) + "/" + out[dir+"go.mod"] = goMod(SeatGoModule(m.GoModule, name)) + out[dir+"contract.go"] = src + if test != nil { + out[dir+"contract_test.go"] = test + } + } + return nil +} + +// SeatGoModule is the Go module of a seat defined whole, beside the seat set's. +func SeatGoModule(root, seat string) string { return root + "/seats/" + pkgName(seat) } + +func goMod(path string) []byte { return []byte("module " + path + "\n\ngo 1.26.0\n") } + +func sortedKeys[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func gofmt(b *bytes.Buffer) ([]byte, error) { + src, err := format.Source(b.Bytes()) + if err != nil { + return nil, fmt.Errorf("the generated Go does not parse: %w\n%s", err, b.String()) + } + return src, nil +} + +// declareAll declares a definition's types: its own named types, then each verb's input and answer, each accept, +// event, holder event, record and provision; a name given by a part (in fromPart) is an alias of the part's. +func declareAll(d seatdef.Definition, fromPart map[string]string) (*typer, error) { + tp := newTyper() + decl := func(name, desc string, s map[string]any) error { + if s == nil { + return nil + } + if _, ok := fromPart[name]; ok { + tp.taken[name] = true + return nil + } + return tp.declare(name, desc, s) + } + for _, n := range d.DefNames() { + if err := decl(n, "", d.Defs[n]); err != nil { + return nil, err + } + } + for _, v := range d.Verbs { + if err := decl(ident(v.Name)+"Input", "is what "+v.Name+" takes.", v.Input); err != nil { + return nil, err + } + if err := decl(ident(v.Name)+"Answer", "is what "+v.Name+" answers.", v.Answer); err != nil { + return nil, err + } + } + for _, a := range d.Accepts { + if err := decl(ident(a.Name)+"Accept", "is the body of "+a.Name+".", a.Body); err != nil { + return nil, err + } + } + for _, e := range d.Events { + if err := decl(ident(e.Name)+"Event", "is the body of the event "+e.Name+".", e.Body); err != nil { + return nil, err + } + } + for _, e := range d.HolderEvents { + if err := decl(ident(e.Name)+"HolderEvent", "is the body of the holder's event "+e.Name+".", e.Body); err != nil { + return nil, err + } + } + for _, st := range d.States { + for _, kf := range st.KeyForms { + vn, err := stateValueName(st.Name, kf) + if err != nil { + return nil, fmt.Errorf("state %s: %w", st.Name, err) + } + if err := decl(vn, "is one value of the state "+st.Name+" under the key "+kf.Key+".", kf.Value); err != nil { + return nil, err + } + } + } + for _, p := range d.Provisions { + if err := decl(ident(p.Name)+"Form", "is the form of the provision "+p.Name+".", p.Form); err != nil { + return nil, err + } + } + return tp, nil +} + +func oneLine(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "no description." + } + return strings.ReplaceAll(s, "\n", " ") +} + +func writeNames(b *bytes.Buffer, prefix string, names []string) { + if len(names) == 0 { + return + } + b.WriteString("const (\n") + for _, n := range names { + fmt.Fprintf(b, "\t%s%s = %q\n", prefix, ident(n), n) + } + b.WriteString(")\n\n") +} + +func writeList(b *bytes.Buffer, fn, prefix, what string, names []string) { + if len(names) == 0 { + return + } + fmt.Fprintf(b, "// %s is %s: a new copy each call.\nfunc %s() []string {\n\treturn []string{", fn, what, fn) + for i, n := range names { + if i > 0 { + b.WriteString(", ") + } + b.WriteString(prefix + ident(n)) + } + b.WriteString("}\n}\n\n") +} + +// goWhole is a whole seat's package: its names, subjects and types, and the test of its fixtures. +func goWhole(m *Module, d seatdef.Definition) ([]byte, []byte, error) { + fromPart := map[string]string{} + tp, err := declareAll(d, fromPart) + if err != nil { + return nil, nil, err + } + pkg := pkgName(d.Seat) + var b bytes.Buffer + b.WriteString(header) + fmt.Fprintf(&b, "// Package %s is the contract of the seat %q, defined whole (seats/%s/seat.json): %s\npackage %s\n\n", + pkg, d.Seat, d.Seat, oneLine(d.Description), pkg) + if len(d.States) > 0 { + b.WriteString(goStatesImports(d.States)) + } + fmt.Fprintf(&b, "// Seat is the seat's name.\nconst Seat = %q\n\n", d.Seat) + writeNames(&b, "Verb", seatdef.VerbNames(d.Verbs)) + writeNames(&b, "Accept", seatdef.MessageNames(d.Accepts)) + writeNames(&b, "Event", seatdef.MessageNames(d.Events)) + writeList(&b, "Verbs", "Verb", "every verb the seat serves, in its definition's order", seatdef.VerbNames(d.Verbs)) + writeList(&b, "Events", "Event", "every event published on the seat's subject", seatdef.MessageNames(d.Events)) + if f := d.Family; f != nil { + fmt.Fprintf(&b, "// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects.\nconst (\n\tFamily = %q\n\tFamilyMaxAgeDays = %d\n)\n\n", f.Name, f.MaxAgeDays) + } + for _, e := range d.Events { + subj := seatdef.EventSubject(d.Seat, e.Name) + if e.ByCaller { + fmt.Fprintf(&b, "// Subject%s is where %s is published for one caller.\nfunc Subject%s(caller string) string { return %q + caller }\n\n", + ident(e.Name), e.Name, ident(e.Name), subj+".") + } else { + fmt.Fprintf(&b, "// Subject%s is where %s is published.\nconst Subject%s = %q\n\n", ident(e.Name), e.Name, ident(e.Name), subj) + } + } + for _, a := range d.Accepts { + subj := seatdef.AcceptSubject(d.Seat, a.Name) + if a.ByCaller { + fmt.Fprintf(&b, "// AcceptSubject%s is where one caller sends %s.\nfunc AcceptSubject%s(caller string) string { return %q + caller }\n\n", + ident(a.Name), a.Name, ident(a.Name), subj+".") + } else { + fmt.Fprintf(&b, "// AcceptSubject%s is where %s is sent.\nconst AcceptSubject%s = %q\n\n", ident(a.Name), a.Name, ident(a.Name), subj) + } + } + if len(d.HolderEvents) > 0 { + b.WriteString("// The holder's own events, listed beside the seat and published under the holder's subjects (ADR 0620 §2).\nconst (\n") + for _, e := range d.HolderEvents { + fmt.Fprintf(&b, "\tHolderEvent%s = %q\n", ident(e.Name), e.Name) + } + b.WriteString(")\n\n") + for _, e := range d.HolderEvents { + before, after, _ := strings.Cut(e.Subject, "") + fmt.Fprintf(&b, "// HolderSubject%s is where the holder publishes %s", ident(e.Name), e.Name) + if e.Family != "" { + fmt.Fprintf(&b, ", kept in %s", e.Family) + } + fmt.Fprintf(&b, ".\nfunc HolderSubject%s(holder string) string { return %q + holder + %q }\n\n", ident(e.Name), before, after) + } + } + if len(d.States) > 0 { + if err := goStates(&b, d.States); err != nil { + return nil, nil, err + } + } + if err := goTypes(&b, tp, map[string]*typ{}); err != nil { + return nil, nil, err + } + if bytes.Contains(b.Bytes(), []byte("time.Time")) { + // A moment is the standard library's time (the only import a contract may have besides its states' readers). + raw := b.Bytes() + if bytes.Contains(raw, []byte("\nimport (\n")) { + raw = bytes.Replace(raw, []byte("\nimport (\n"), []byte("\nimport (\n\t\"time\"\n"), 1) + } else { + raw = bytes.Replace(raw, []byte("package "+pkg+"\n\n"), []byte("package "+pkg+"\n\nimport \"time\"\n\n"), 1) + } + b = *bytes.NewBuffer(raw) + } + src, err := gofmt(&b) + if err != nil { + return nil, nil, err + } + test, err := goFixtures(m, d, pkg, tp, fromPart) + return src, test, err +} + +// goTypes writes every declared type; also are the kinds of types declared elsewhere (a part's), by name. +func goTypes(b *bytes.Buffer, tp *typer, also map[string]*typ) error { + kinds := map[string]*typ{} + for k, v := range also { + kinds[k] = v + } + for _, t := range tp.types { + kinds[t.name] = t.t + } + for _, t := range tp.types { + desc := strings.TrimSpace(t.desc) + if desc == "" { + desc = "is a type of the contract." + } else if !strings.HasPrefix(desc, "is ") { + desc = "is " + strings.TrimSuffix(desc, ".") + "." + } + fmt.Fprintf(b, "// %s %s\n", t.name, oneLine(desc)) + if t.t.kind != "struct" { + fmt.Fprintf(b, "type %s = %s\n\n", t.name, goType(t.t, kinds, true)) + goEnum(b, t.name, t.name, t.t) + continue + } + fmt.Fprintf(b, "type %s struct {\n", t.name) + for _, f := range t.t.fields { + if f.desc != "" { + fmt.Fprintf(b, "\t// %s\n", oneLine(f.desc)) + } + tag := f.json + if !f.required { + tag += ",omitempty" + } + fmt.Fprintf(b, "\t%s %s `json:%q`\n", ident(f.json), goType(f.t, kinds, f.required), tag) + } + b.WriteString("}\n\n") + for _, f := range t.t.fields { + goEnum(b, t.name+"'s "+f.json, t.name+ident(f.json), f.t) + } + } + return nil +} + +// goEnum writes a string's enum as one constant per value, named prefix and the value (RemoveInputReasonRetention), so a +// caller names a value the definition gives and never writes it by hand. +func goEnum(b *bytes.Buffer, what, prefix string, t *typ) { + if t.kind != "string" || len(t.enum) == 0 { + return + } + fmt.Fprintf(b, "// The values of %s.\nconst (\n", what) + for _, v := range t.enum { + fmt.Fprintf(b, "\t%s%s = %q\n", prefix, ident(v), v) + } + b.WriteString(")\n\n") +} + +func goType(t *typ, kinds map[string]*typ, required bool) string { + if t.null { + n := *t + n.null = false + return "*" + goType(&n, kinds, true) + } + switch t.kind { + case "string": + return "string" + case "time": + if required { + return "time.Time" + } + return "*time.Time" + case "integer": + return "int64" + case "number": + return "float64" + case "boolean": + return "bool" + case "object": + return "map[string]any" + case "map": + return "map[string]" + goType(t.elem, kinds, true) + case "slice": + return "[]" + goType(t.elem, kinds, true) + case "ref": + if k, ok := kinds[t.ref]; ok && k.kind == "struct" && !required { + return "*" + t.ref + } + return t.ref + } + return "any" +} + +// goFixtures is the test that every fixture of the seat decodes into its type and reads back as its wire says, and +// that the subject it names is the one the contract gives. +func goFixtures(m *Module, d seatdef.Definition, pkg string, tp *typer, fromPart map[string]string) ([]byte, error) { + fx := m.Fixtures[d.Seat] + if len(fx) == 0 { + return nil, nil + } + has := map[string]bool{} + for _, t := range tp.types { + has[t.name] = true + } + for n := range fromPart { + has[n] = true + } + var b bytes.Buffer + b.WriteString(header) + fmt.Fprintf(&b, "package %s\n\nimport (\n\t\"encoding/json\"\n\t\"reflect\"\n\t\"regexp\"\n\t\"testing\"\n)\n\n", pkg) + fmt.Fprintf(&b, "// TestFixtures reads every fixture of seats/%s/fixtures: each decodes into its type, a field the type does not\n"+ + "// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's.\n", d.Seat) + b.WriteString("func TestFixtures(t *testing.T) {\n\tcases := []struct {\n\t\tname, subject, given, wire string\n\t\tdecode func([]byte) (any, error)\n\t\tsubjectIs *regexp.Regexp\n\t}{\n") + for _, f := range fx { + kind, name, _ := f.Instance() + typeName, pattern, err := fixtureTarget(d, kind, name) + if err != nil { + return nil, fmt.Errorf("fixture %q: %w", f.Name, err) + } + given, _ := json.Marshal(f.Given) + wire := given + if f.Wire != nil { + wire, _ = json.Marshal(f.Wire) + } + decoder := "func(b []byte) (any, error) { var v map[string]any; err := json.Unmarshal(b, &v); return v, err }" + if has[typeName] { + decoder = fmt.Sprintf("func(b []byte) (any, error) { var v %s; err := json.Unmarshal(b, &v); return v, err }", typeName) + } + subjectIs := "nil" + if pattern != "" { + subjectIs = "regexp.MustCompile(" + strconv.Quote(pattern) + ")" + } else if f.Subject != "" { + return nil, fmt.Errorf("fixture %q names a subject, and a verb's message has none in the contract", f.Name) + } + if pattern != "" && f.Subject == "" { + return nil, fmt.Errorf("fixture %q of %s names no subject (design 26: each event's subject is named)", f.Name, f.Of) + } + fmt.Fprintf(&b, "\t\t{name: %q, subject: %q, given: %s, wire: %s,\n\t\t\tdecode: %s, subjectIs: %s},\n", + f.Name, f.Subject, strconv.Quote(string(given)), strconv.Quote(string(wire)), decoder, subjectIs) + } + b.WriteString(` } + for _, c := range cases { + v, err := c.decode([]byte(c.given)) + if err != nil { + t.Errorf("%s: does not decode: %v", c.name, err) + continue + } + back, err := json.Marshal(v) + if err != nil { + t.Errorf("%s: does not encode: %v", c.name, err) + continue + } + var got, want any + if err := json.Unmarshal(back, &got); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(c.wire), &want); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire) + } + if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) { + t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs) + } + } +} +`) + return gofmt(&b) +} + +// fixtureTarget is the type a fixture decodes into and the pattern its subject must match, if it has one. +func fixtureTarget(d seatdef.Definition, kind, name string) (string, string, error) { + switch kind { + case "verb-input", "verb-answer": + for _, v := range d.Verbs { + if v.Name == name { + if kind == "verb-input" { + return ident(name) + "Input", "", nil + } + return ident(name) + "Answer", "", nil + } + } + case "accept", "event": + ms, subj := d.Accepts, seatdef.AcceptSubject(d.Seat, name) + suffix := "Accept" + if kind == "event" { + ms, subj, suffix = d.Events, seatdef.EventSubject(d.Seat, name), "Event" + } + for _, msg := range ms { + if msg.Name == name { + p := "^" + regexp(subj) + if msg.ByCaller { + p += `\.[A-Za-z0-9_-]+` + } + return ident(name) + suffix, p + "$", nil + } + } + case "state": + if n, ok := stateFormFor(d, name); ok { + return n, "", nil + } + case "holder-event": + for _, e := range d.HolderEvents { + if e.Name == name { + before, after, _ := strings.Cut(e.Subject, "") + p := "^" + regexp(before) + if strings.Contains(e.Subject, "") { + p += "[a-z0-9-]+" + regexp(after) + } + return ident(name) + "HolderEvent", p + "$", nil + } + } + } + return "", "", fmt.Errorf("the definition has no %s %q", kind, name) +} + +func regexp(s string) string { + var b strings.Builder + for _, r := range s { + if strings.ContainsRune(`\.+*?()|[]{}^$`, r) { + b.WriteRune('\\') + } + b.WriteRune(r) + } + return b.String() +} diff --git a/modules/mesh-build-go/buildstep/seatgen/layout_test.go b/modules/mesh-build-go/buildstep/seatgen/layout_test.go new file mode 100644 index 000000000..f038c020f --- /dev/null +++ b/modules/mesh-build-go/buildstep/seatgen/layout_test.go @@ -0,0 +1,179 @@ +package seatgen + +import ( + "encoding/json" + "go/parser" + "go/token" + re "regexp" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// TestEachSeatDefinedWholeIsAGoModuleOfItsOwn is ADR 0625's generator check (§1, §2, §3): there is no seat set and no +// root Go module (ADR 0635 §1), each seat defined whole is a Go module of its own holding its package alone, the +// parts it includes are written into it, a definition at the seat-list grain generates nothing, and no generated Go file +// imports anything outside the standard library. +func TestEachSeatDefinedWholeIsAGoModuleOfItsOwn(t *testing.T) { + m, err := Load("testdata/contracts", "example.org/contracts/go", "@example/contracts") + if err != nil { + t.Fatal(err) + } + files, err := Generate(m) + if err != nil { + t.Fatal(err) + } + mods := map[string]string{} + for p, b := range files { + if strings.HasPrefix(p, "go/") && strings.HasSuffix(p, "go.mod") { + mods[strings.TrimSuffix(p, "go.mod")] = strings.SplitN(string(b), "\n", 2)[0] + } + } + want := map[string]string{ + "go/seats/aregistry/": "module example.org/contracts/go/seats/aregistry", + "go/seats/bregistry/": "module example.org/contracts/go/seats/bregistry", + } + for dir, line := range want { + if mods[dir] != line { + t.Errorf("%sgo.mod says %q, want %q", dir, mods[dir], line) + } + } + // Every seat the definitions mark whole has its own module, whatever the seat set says of it (ADR 0627: the seat + // set marks ten whole, and none is without its package). + for name, d := range m.Seats { + if d.Grain == seatdef.Whole && mods["go/seats/"+pkgName(name)+"/"] == "" { + t.Errorf("the seat %s is defined whole and has no Go module of its own", name) + } + } + if len(mods) != len(want) { + t.Errorf("Go modules %v, want %v", mods, want) + } + for p, b := range files { + if !strings.HasPrefix(p, "go/") || !strings.HasSuffix(p, ".go") { + continue + } + dir := p[:strings.LastIndex(p, "/")+1] + if mods[dir] == "" { + t.Errorf("%s is in no seat's own Go module", p) + } + f, err := parser.ParseFile(token.NewFileSet(), p, b, parser.ImportsOnly) + if err != nil { + t.Fatal(err) + } + for _, im := range f.Imports { + if path := strings.Trim(im.Path.Value, `"`); strings.Contains(strings.SplitN(path, "/", 2)[0], ".") { + t.Errorf("%s imports %s: a generated package imports nothing outside the standard library", p, path) + } + } + } + for _, seat := range []string{"aregistry", "bregistry"} { + src := string(files["go/seats/"+seat+"/contract.go"]) + if !strings.Contains(src, "type Ref struct") || !strings.Contains(src, "type PublishedEvent") { + t.Errorf("%s does not hold the part's types whole", seat) + } + // A moment (format date-time) is the standard library's time in Go, a required one by value and an optional one + // by pointer (a zero time is not an absent one), and a string in TypeScript. + if !re.MustCompile(`Pushed\s+time\.Time\s+`+"`json:\"pushed\"`").MatchString(src) || !re.MustCompile(`At\s+\*time\.Time\s+`+"`json:\"at,omitempty\"`").MatchString(src) || + !(strings.Contains(src, "\t\"time\"\n") || strings.Contains(src, "import \"time\"")) { + t.Errorf("%s does not write a date-time as time.Time:\n%s", seat, src) + } + if ts := string(files["ts/seats/"+seat+"/src/parts/registry.ts"]); !strings.Contains(ts, `"pushed": string;`) || !strings.Contains(ts, `"at"?: string;`) { + t.Errorf("%s's TypeScript does not write a date-time as a string:\n%s", seat, ts) + } + // A scalar typed ["T","null"] is a pointer in Go and `T | null` in TypeScript, so a declared zero survives with its type. + for _, w := range []string{`Floor\s+\*float64\s+` + "`json:\"floor,omitempty\"`", `Limit\s+\*int64\s+` + "`json:\"limit,omitempty\"`", `Label\s+\*string\s+` + "`json:\"label,omitempty\"`", `On\s+\*bool\s+` + "`json:\"on,omitempty\"`"} { + if !re.MustCompile(w).MatchString(src) { + t.Errorf("%s does not write a nullable scalar as a pointer (%s):\n%s", seat, w, src) + } + } + if ts := string(files["ts/seats/"+seat+"/src/parts/registry.ts"]); !strings.Contains(ts, `"floor"?: number | null;`) || !strings.Contains(ts, `"on"?: boolean | null;`) || !strings.Contains(ts, `"label"?: string | null;`) { + t.Errorf("%s's TypeScript does not write a nullable scalar as T | null:\n%s", seat, ts) + } + // A string's enum is a constant per value, so a caller names the definition's value (A3t: the removal reason). + if !re.MustCompile(`RefReasonRetention\s+= "retention"`).MatchString(src) || !re.MustCompile(`RefReasonTagGone\s+= "tag-gone"`).MatchString(src) { + t.Errorf("%s does not name the part's enum values:\n%s", seat, src) + } + } + for p := range files { + if strings.Contains(p, "onestore") || p == "go/seats/seats.go" || p == "go/go.mod" || p == "ts/package.json" || p == "ts/src/seats.ts" { + t.Errorf("%s is written: a seat list is not generated, and a definition at its grain generates nothing", p) + } + } + + // The npm side (ADR 0625 §1): the indexer counts an npm package a lock resolves, so each seat defined whole is an + // npm package of its own (`-` at ts/seats/), holding its parts' types whole and + // importing nothing outside itself; there is no package above them. + pkgs := map[string]string{} + for p, b := range files { + if strings.HasPrefix(p, "ts/") && strings.HasSuffix(p, "package.json") { + var pj struct { + Name string `json:"name"` + } + if err := json.Unmarshal(b, &pj); err != nil { + t.Fatalf("%s: %v", p, err) + } + pkgs[strings.TrimSuffix(p, "package.json")] = pj.Name + } + } + wantPkgs := map[string]string{ + "ts/seats/aregistry/": "@example/contracts-a-registry", + "ts/seats/bregistry/": "@example/contracts-b-registry", + } + if len(pkgs) != len(wantPkgs) { + t.Errorf("npm packages %v, want %v", pkgs, wantPkgs) + } + for dir, name := range wantPkgs { + if pkgs[dir] != name { + t.Errorf("%spackage.json names %q, want %q", dir, pkgs[dir], name) + } + } + for p, b := range files { + if !strings.HasPrefix(p, "ts/") || !strings.HasSuffix(p, ".ts") { + continue + } + dir := "" + for d := range pkgs { + if strings.HasPrefix(p, d) && len(d) > len(dir) { + dir = d + } + } + if dir == "" || !strings.HasPrefix(p, dir+"src/") { + t.Errorf("%s is in no npm package's src", p) + } + for _, line := range strings.Split(string(b), "\n") { + i := strings.Index(line, ` from "`) + if l := strings.TrimSpace(line); i >= 0 && (strings.HasPrefix(l, "import") || strings.HasPrefix(l, "export")) { + from := strings.Trim(line[i+len(` from `):], `";`) + if !strings.HasPrefix(from, "./") { + t.Errorf("%s imports %s: a generated npm package imports only its own files", p, from) + } + } + } + } + // F7: a published package is built from its own lock (mesh-build-node refuses one with none), and a generated one has + // no dependency to lock: its lock names the package alone and its package.json has no script and no dependency. + for _, seat := range []string{"aregistry", "bregistry"} { + var pj, lock map[string]any + if err := json.Unmarshal(files["ts/seats/"+seat+"/package.json"], &pj); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(files["ts/seats/"+seat+"/package-lock.json"], &lock); err != nil { + t.Fatalf("%s has no readable package-lock.json: %v", seat, err) + } + for _, k := range []string{"scripts", "dependencies", "devDependencies"} { + if _, ok := pj[k]; ok { + t.Errorf("%s's package.json has %s: a generated package needs no compiler or dependency", seat, k) + } + } + if pk, _ := lock["packages"].(map[string]any); len(pk) != 1 || lock["name"] != pj["name"] { + t.Errorf("%s's lock %v does not name its package alone", seat, lock) + } + } + for _, seat := range []string{"aregistry", "bregistry"} { + if !strings.Contains(string(files["ts/seats/"+seat+"/src/index.ts"]), "export type PublishedEvent = registry.PublishedEvent") || + !strings.Contains(string(files["ts/seats/"+seat+"/src/parts/registry.ts"]), "export type PublishedEvent") { + t.Errorf("the npm package of %s does not hold the part's types whole", seat) + } + } +} diff --git a/modules/mesh-build-go/buildstep/seatgen/load.go b/modules/mesh-build-go/buildstep/seatgen/load.go new file mode 100644 index 000000000..697dba1c0 --- /dev/null +++ b/modules/mesh-build-go/buildstep/seatgen/load.go @@ -0,0 +1,201 @@ +// Package seatgen writes a contracts module's Go and TypeScript from its seat definitions (design 26 *The +// generator*): the seat list every reader compiles, and for each seat defined whole its names, subjects and message +// types, with a test of its fixtures. The same input gives the same bytes. It is the build modules' logic (the Go +// build module's for Go, the Node build module's for TypeScript); this copy wrote mesh-contracts' first files and is +// handed to them as it stands. +package seatgen + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// Module is a contracts module read from its directory. +type Module struct { + // GoModule is the Go module path of the generated Go tree (`/go`). + GoModule string + // TSPackage is the npm package's name. + TSPackage string + // DomainSDK is the Go module path of the contracts module's domain SDK (ADR 0634 §2), which a seat's client may + // import beside its contract and mesh-sdk's runtime; empty when the domain has none. + DomainSDK string + // Order is every seat's name, sorted: a contracts module has no seat list and no reading order (ADR 0635 §1). + Order []string + // Seats are every definition, resolved, by name. + Seats map[string]seatdef.Definition + // Raw are every definition as written, its parts not taken in. + Raw map[string]seatdef.Definition + // Parts are every part, by name. + Parts map[string]seatdef.Part + // Fixtures are each seat's fixtures, by seat, in file order. + Fixtures map[string][]seatdef.Fixture +} + +// Load reads a contracts module from its directory, wherever that is in its repository (ADR 0634 §8): +// `seats//seat.json`, `seats//fixtures/*.json` and `parts/*.json`. A definition at the seat-list grain is +// read and checked like the rest and generates nothing. goModule and tsPackage name the generated trees' roots; a +// module with no seat defined whole needs neither. +func Load(dir, goModule, tsPackage string) (*Module, error) { + m := &Module{GoModule: goModule, TSPackage: tsPackage, Seats: map[string]seatdef.Definition{}, + Raw: map[string]seatdef.Definition{}, Parts: map[string]seatdef.Part{}, Fixtures: map[string][]seatdef.Fixture{}} + parts, _ := filepath.Glob(filepath.Join(dir, "parts", "*.json")) + sort.Strings(parts) + for _, p := range parts { + raw, err := os.ReadFile(p) + if err != nil { + return nil, err + } + part, err := seatdef.ParsePart(raw) + if err != nil { + return nil, fmt.Errorf("%s: %w", rel(dir, p), err) + } + if want := strings.TrimSuffix(filepath.Base(p), ".json"); part.Part != want { + return nil, fmt.Errorf("%s: names part %q", rel(dir, p), part.Part) + } + m.Parts[part.Part] = part + } + dirs, _ := filepath.Glob(filepath.Join(dir, "seats", "*", "seat.json")) + for _, p := range dirs { + name := filepath.Base(filepath.Dir(p)) + raw, err := os.ReadFile(p) + if err != nil { + return nil, err + } + d, err := seatdef.Parse(raw) + if err != nil { + return nil, fmt.Errorf("%s: %w", rel(dir, p), err) + } + if d.Seat != name { + return nil, fmt.Errorf("%s: names seat %q", rel(dir, p), d.Seat) + } + m.Raw[name] = d + m.Order = append(m.Order, name) + resolved, err := seatdef.Resolve(d, m.Parts) + if err != nil { + return nil, fmt.Errorf("%s: %w", rel(dir, p), err) + } + m.Seats[name] = resolved + fx, _ := filepath.Glob(filepath.Join(filepath.Dir(p), "fixtures", "*.json")) + sort.Strings(fx) + for _, f := range fx { + raw, err := os.ReadFile(f) + if err != nil { + return nil, err + } + cases, err := seatdef.ParseFixtures(raw) + if err != nil { + return nil, fmt.Errorf("%s: %w", rel(dir, f), err) + } + m.Fixtures[name] = append(m.Fixtures[name], cases...) + } + } + sort.Strings(m.Order) + return m, nil +} + +func rel(dir, p string) string { + if r, err := filepath.Rel(dir, p); err == nil { + return r + } + return p +} + +// Generate is every file the module's generated trees hold, by path from the module's root. +func Generate(m *Module) (map[string][]byte, error) { + for _, name := range m.Order { + if m.Seats[name].Grain == seatdef.Whole && (m.GoModule == "" || m.TSPackage == "") { + return nil, fmt.Errorf("the seat %s is defined whole and the module names no generated Go module or TypeScript package yet: give them to the generator (seatgen -go-module, -ts-package)", name) + } + } + out := map[string][]byte{} + if err := goFiles(m, out); err != nil { + return nil, err + } + if err := tsFiles(m, out); err != nil { + return nil, err + } + return out, nil +} + +// GeneratedRoots are the directories every file of which the generator writes: a file there it did not write is +// out of step (design 26: the compare step). +var GeneratedRoots = []string{"go", "ts"} + +// Compare is every path out of step between what the generator writes and the tree in dir: a file that differs, one +// missing, or one under a generated root the generator did not write, each named once, sorted. +func Compare(dir string, files map[string][]byte) ([]string, error) { + var out []string + for p, want := range files { + got, err := os.ReadFile(filepath.Join(dir, p)) + switch { + case os.IsNotExist(err): + out = append(out, p+": missing") + case err != nil: + return nil, err + case string(got) != string(want): + out = append(out, p+": differs") + } + } + for _, root := range GeneratedRoots { + err := filepath.WalkDir(filepath.Join(dir, root), func(p string, e os.DirEntry, err error) error { + if err != nil { + if os.IsNotExist(err) { + return filepath.SkipDir + } + return err + } + if e.IsDir() { + if e.Name() == "node_modules" || e.Name() == "dist" || rel(dir, p) == ClientRoot { + return filepath.SkipDir + } + return nil + } + r := rel(dir, p) + if _, ok := files[r]; !ok { + out = append(out, r+": not written by the generator") + } + return nil + }) + if err != nil { + return nil, err + } + } + sort.Strings(out) + return out, nil +} + +// pkgName is a seat's or part's Go package: its name without hyphens. +func pkgName(name string) string { return strings.ReplaceAll(name, "-", "") } + +// ident is a name in Go's exported form: mesh-broker → MeshBroker, publish-part → PublishPart, preview.written → +// PreviewWritten, local-name → LocalName, id → ID. +func ident(name string) string { + var b strings.Builder + for _, w := range strings.FieldsFunc(name, func(r rune) bool { return r == '-' || r == '_' || r == '.' || r == ' ' }) { + switch w { + case "id": + b.WriteString("ID") + case "url": + b.WriteString("URL") + case "mib": + b.WriteString("MiB") + default: + b.WriteString(strings.ToUpper(w[:1]) + w[1:]) + } + } + return b.String() +} + +// tsName is a name in TypeScript's camel form for a constant: publish-part → publishPart. +func tsName(name string) string { + id := ident(name) + if strings.HasPrefix(id, "ID") { + return "id" + id[2:] + } + return strings.ToLower(id[:1]) + id[1:] +} diff --git a/modules/mesh-build-go/buildstep/seatgen/states.go b/modules/mesh-build-go/buildstep/seatgen/states.go new file mode 100644 index 000000000..71bb54dd0 --- /dev/null +++ b/modules/mesh-build-go/buildstep/seatgen/states.go @@ -0,0 +1,256 @@ +package seatgen + +import ( + "bytes" + "fmt" + "go/token" + "strconv" + "strings" + + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// A keyToken is one piece of a key form's pattern: a literal, or a named part (``). +type keyToken struct { + part bool + text string +} + +// keyTokens reads a key form's pattern into its pieces. Two parts with no literal between them are refused: a reader +// could not tell where the first ends. +func keyTokens(key string) ([]keyToken, error) { + var out []keyToken + rest := key + for rest != "" { + i := strings.Index(rest, "<") + if i < 0 { + out = append(out, keyToken{text: rest}) + break + } + if i > 0 { + out = append(out, keyToken{text: rest[:i]}) + } + j := strings.Index(rest[i:], ">") + if j < 0 { + return nil, fmt.Errorf("key %q: a part is not closed", key) + } + out = append(out, keyToken{part: true, text: rest[i+1 : i+j]}) + rest = rest[i+j+1:] + } + for i := 1; i < len(out); i++ { + if out[i].part && out[i-1].part { + return nil, fmt.Errorf("key %q: two parts with no literal between them", key) + } + } + return out, nil +} + +func keyParts(ts []keyToken) []string { + var out []string + for _, t := range ts { + if t.part { + out = append(out, t.text) + } + } + return out +} + +// formIdent is the name a key form's functions and value type are built from: the state's name, then "By" and the +// form's parts and literals in order (asks, `.` → AsksByAskerID). +func formIdent(state, key string, ts []keyToken) string { + var b strings.Builder + b.WriteString(ident(state) + "By") + for _, t := range ts { + b.WriteString(ident(t.text)) + } + return b.String() +} + +var reservedWords = map[string]bool{"default": true, "new": true, "delete": true, "class": true, "function": true, "var": true, "let": true, "const": true, "in": true, "of": true} + +// paramName is a part's name as a function parameter: local-name → localName. +func paramName(part string) string { + n := ident(part) + n = strings.ToLower(n[:1]) + n[1:] + if strings.HasPrefix(ident(part), "ID") { + n = "id" + ident(part)[2:] + } + if token.IsKeyword(n) || reservedWords[n] { + n += "Part" + } + return n +} + +// stateValueName is the Go and TypeScript type of one value of a state under a key form. +func stateValueName(state string, kf seatdef.KeyForm) (string, error) { + ts, err := keyTokens(kf.Key) + if err != nil { + return "", err + } + return formIdent(state, kf.Key, ts) + "Value", nil +} + +// stateFormFor is the state and key form a fixture's `state:/` names. +func stateFormFor(d seatdef.Definition, name string) (string, bool) { + state, key, _ := strings.Cut(name, "/") + for _, s := range d.States { + if s.Name != state { + continue + } + for _, kf := range s.KeyForms { + if kf.Key == key { + n, err := stateValueName(s.Name, kf) + return n, err == nil + } + } + } + return "", false +} + +// goStates writes a seat's states: a constant for each local name, and for each key form its builder, its parser and +// the strict reader of a value (a field the type does not know is refused). +func goStates(b *bytes.Buffer, states []seatdef.State) error { + names := make([]string, len(states)) + for i, s := range states { + names[i] = s.Name + } + writeNames(b, "State", names) + for _, s := range states { + for _, kf := range s.KeyForms { + ts, err := keyTokens(kf.Key) + if err != nil { + return fmt.Errorf("state %s: %w", s.Name, err) + } + fi := formIdent(s.Name, kf.Key, ts) + params := keyParts(ts) + var plist, expr []string + for _, t := range ts { + if t.part { + expr = append(expr, paramName(t.text)) + } else { + expr = append(expr, fmt.Sprintf("%q", t.text)) + } + } + for _, p := range params { + plist = append(plist, paramName(p)) + } + sig := "" + if len(plist) > 0 { + sig = strings.Join(plist, ", ") + " string" + } + fmt.Fprintf(b, "// %sKey is the key of one value of the state %s under the form %q.\nfunc %sKey(%s) string { return %s }\n\n", + fi, s.Name, kf.Key, fi, sig, strings.Join(expr, " + ")) + rets := make([]string, 0, len(plist)+1) + zero := make([]string, 0, len(plist)+1) + for range plist { + rets = append(rets, "string") + zero = append(zero, `""`) + } + rets = append(rets, "bool") + zero = append(zero, "false") + fmt.Fprintf(b, "// Parse%sKey reads the parts of a key of the form %q, in the order of the form; the last result is false for a key of another form.\nfunc Parse%sKey(key string) (%s) {\n", + fi, kf.Key, fi, strings.Join(rets, ", ")) + fail := "return " + strings.Join(zero, ", ") + b.WriteString("\trest := key\n") + var got []string + for i := 0; i < len(ts); { + t := ts[i] + switch { + case !t.part: + fmt.Fprintf(b, "\tif !strings.HasPrefix(rest, %q) {\n\t\t%s\n\t}\n\trest = rest[%d:]\n", t.text, fail, len(t.text)) + i++ + case i+1 < len(ts): + fmt.Fprintf(b, "\tv%d, after%d, found%d := strings.Cut(rest, %q)\n\tif !found%d || v%d == \"\" {\n\t\t%s\n\t}\n\trest = after%d\n", + i, i, i, ts[i+1].text, i, i, fail, i) + got = append(got, fmt.Sprintf("v%d", i)) + i += 2 + default: + fmt.Fprintf(b, "\tif rest == \"\" {\n\t\t%s\n\t}\n\tv%d := rest\n\trest = \"\"\n", fail, i) + got = append(got, fmt.Sprintf("v%d", i)) + i++ + } + } + b.WriteString("\tif rest != \"\" {\n\t\t" + fail + "\n\t}\n") + fmt.Fprintf(b, "\treturn %s\n}\n\n", strings.Join(append(got, "true"), ", ")) + vn := fi + "Value" + fmt.Fprintf(b, "// Decode%s reads one value of the state %s under the form %q, refusing a field %s does not know and any text after the value.\n"+ + "func Decode%s(raw []byte) (%s, error) {\n\tvar v %s\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.DisallowUnknownFields()\n"+ + "\tif err := dec.Decode(&v); err != nil {\n\t\treturn v, err\n\t}\n\tif dec.More() {\n\t\treturn v, errors.New(\"text after the value\")\n\t}\n\treturn v, nil\n}\n\n", + vn, s.Name, kf.Key, vn, vn, vn, vn) + } + } + return nil +} + +// goStatesImports is the import block a seat's states need: the strict reader always, the parser's string functions +// where a key form has a literal or a part that is not last. +func goStatesImports(states []seatdef.State) string { + needStrings := false + for _, s := range states { + for _, kf := range s.KeyForms { + ts, _ := keyTokens(kf.Key) + for i, t := range ts { + if !t.part || i+1 < len(ts) { + needStrings = true + } + } + } + } + out := "import (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n" + if needStrings { + out += "\t\"strings\"\n" + } + return out + ")\n\n" +} + +// tsStates writes a seat's states for TypeScript: the local names, and for each key form its builder and parser (the +// value's type is declared with the seat's other types). +func tsStates(b *bytes.Buffer, states []seatdef.State) error { + names := make([]string, len(states)) + for i, s := range states { + names[i] = s.Name + } + tsNames(b, "states", names) + for _, s := range states { + for _, kf := range s.KeyForms { + ts, err := keyTokens(kf.Key) + if err != nil { + return fmt.Errorf("state %s: %w", s.Name, err) + } + fn := tsName(formIdent(s.Name, kf.Key, ts)) + var args, expr, fields, got []string + for _, t := range ts { + if t.part { + p := paramName(t.text) + args = append(args, p+": string") + expr = append(expr, p) + fields = append(fields, p+": string") + got = append(got, p) + } else { + expr = append(expr, strconv.Quote(t.text)) + } + } + fmt.Fprintf(b, "/** The key of one value of the state %s under the form %s. */\nexport function %sKey(%s): string {\n return %s;\n}\n\n", + s.Name, strconv.Quote(kf.Key), fn, strings.Join(args, ", "), strings.Join(expr, " + ")) + fmt.Fprintf(b, "/** The parts of a key of the form %s, or undefined for a key of another form. */\nexport function parse%sKey(_key: string): { %s } | undefined {\n let _rest = _key;\n", + strconv.Quote(kf.Key), ident(fn), strings.Join(fields, "; ")) + for i := 0; i < len(ts); { + t := ts[i] + switch { + case !t.part: + fmt.Fprintf(b, " if (!_rest.startsWith(%s)) return undefined;\n _rest = _rest.slice(%d);\n", strconv.Quote(t.text), len(t.text)) + i++ + case i+1 < len(ts): + fmt.Fprintf(b, " const _at%d = _rest.indexOf(%s);\n if (_at%d <= 0) return undefined;\n const %s = _rest.slice(0, _at%d);\n _rest = _rest.slice(_at%d + %d);\n", + i, strconv.Quote(ts[i+1].text), i, paramName(t.text), i, i, len(ts[i+1].text)) + i += 2 + default: + fmt.Fprintf(b, " if (_rest === \"\") return undefined;\n const %s = _rest;\n _rest = \"\";\n", paramName(t.text)) + i++ + } + } + fmt.Fprintf(b, " if (_rest !== \"\") return undefined;\n return { %s };\n}\n\n", strings.Join(got, ", ")) + } + } + return nil +} diff --git a/modules/mesh-build-go/buildstep/seatgen/states_test.go b/modules/mesh-build-go/buildstep/seatgen/states_test.go new file mode 100644 index 000000000..be565d7fd --- /dev/null +++ b/modules/mesh-build-go/buildstep/seatgen/states_test.go @@ -0,0 +1,118 @@ +package seatgen + +import ( + "os" + "os/exec" + "path/filepath" + re "regexp" + "strings" + "testing" +) + +// TestAStateHasItsNamesKeyFormsAndStrictValues is ADR 0634 §6's generator check for `states`: a seat's states give a +// constant per local name, per key form a builder, a parser and a strict reader of the value, and the fixtures of +// `state:` decode into those types. The generated seat package is written out and run with `go vet` and `go test`, so +// what the generator writes is compiled, not only read. +func TestAStateHasItsNamesKeyFormsAndStrictValues(t *testing.T) { + m, err := Load("testdata/contracts", "example.org/contracts/go", "@example/contracts") + if err != nil { + t.Fatal(err) + } + files, err := Generate(m) + if err != nil { + t.Fatal(err) + } + src := string(files["go/seats/aregistry/contract.go"]) + for _, want := range []string{ + "func AsksByAskerIDKey(asker, id string) string", "func ParseAsksByAskerIDKey(key string) (string, string, bool)", + "func DecodeAsksByAskerIDValue(raw []byte)", "type AsksByAskerIDValue struct", + "func AsksByAskerIDDoneKey(", "func AsksByDefaultsKey() string", + "func LocalNameBySeenIDOfAskKey(idOfAsk string) string", + } { + if !strings.Contains(src, want) { + t.Errorf("the seat's package lacks %q:\n%s", want, src) + } + } + for _, c := range []string{`StateAsks\s+= "asks"`, `StateLocalName\s+= "local-name"`} { + if !re.MustCompile(c).MatchString(src) { + t.Errorf("the seat's package lacks a constant %s", c) + } + } + if strings.Contains(src, "Record") { + t.Errorf("the seat's package still names a record:\n%s", src) + } + ts := string(files["ts/seats/aregistry/src/index.ts"]) + for _, want := range []string{"export const states = {", "export function asksByAskerIDKey(asker: string, id: string): string", + "export function parseAsksByAskerIDKey(_key: string)", "export interface AsksByAskerIDValue", + // a key form with a part named key: the parser's own parameter and locals are named apart from the parts + "export function parseAsksByNodeModuleKeyKey(_key: string): { node: string; module: string; key: string } | undefined", + "const key = _rest;"} { + if !strings.Contains(ts, want) { + t.Errorf("the seat's npm package lacks %q:\n%s", want, ts) + } + } + + dir := t.TempDir() + for p, b := range files { + if !strings.HasPrefix(p, "go/seats/aregistry/") { + continue + } + dst := filepath.Join(dir, strings.TrimPrefix(p, "go/seats/aregistry/")) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, b, 0o644); err != nil { + t.Fatal(err) + } + } + extra := `package aregistry + +import "testing" + +func TestKeysAndValues(t *testing.T) { + if k := AsksByAskerIDKey("a", "7"); k != "a.7" { + t.Fatal(k) + } + if a, id, ok := ParseAsksByAskerIDKey("a.7"); !ok || a != "a" || id != "7" { + t.Fatal(a, id, ok) + } + for _, bad := range []string{"", "a", ".7", "a.", "a.7.done"} { + if bad == "a.7.done" { + continue // the id takes the rest: another form is told apart by its own parser + } + if _, _, ok := ParseAsksByAskerIDKey(bad); ok { + t.Errorf("%q parsed", bad) + } + } + if a, id, ok := ParseAsksByAskerIDDoneKey("a.7.done"); !ok || a != "a" || id != "7" { + t.Fatal(a, id, ok) + } + if _, _, ok := ParseAsksByAskerIDDoneKey("a.7"); ok { + t.Error("a key without its literal parsed") + } + if !ParseAsksByDefaultsKey("defaults") || ParseAsksByDefaultsKey("defaultsx") { + t.Error("a literal key") + } + if _, err := DecodeAsksByAskerIDValue([]byte(` + "`" + `{"state":"open","extra":1}` + "`" + `)); err == nil { + t.Error("a field the type does not know was taken") + } + if _, err := DecodeAsksByAskerIDValue([]byte(` + "`" + `{"state":"open"} {}` + "`" + `)); err == nil { + t.Error("text after the value was taken") + } + if v, err := DecodeAsksByAskerIDValue([]byte(` + "`" + `{"state":"open","since":3}` + "`" + `)); err != nil || v.State != "open" { + t.Error(v, err) + } +} +` + if err := os.WriteFile(filepath.Join(dir, "extra_test.go"), []byte(extra), 0o644); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"vet", "./..."}, {"test", "./..."}} { + cmd := exec.Command("go", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOWORK=off", "GOFLAGS=-mod=mod") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("go %s on the generated package: %v\n%s", strings.Join(args, " "), err, out) + } + } +} diff --git a/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/parts/registry.json b/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/parts/registry.json new file mode 100644 index 000000000..d5a44e1ba --- /dev/null +++ b/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/parts/registry.json @@ -0,0 +1,3 @@ +{"format":1,"part":"registry","verbs":[{"name":"publish","input":{"$ref":"#/defs/Ref"},"answer":{"type":"object"}}], + "events":[{"name":"published","body":{"$ref":"#/defs/Ref"}}], + "defs":{"Ref":{"type":"object","properties":{"digest":{"type":"string"},"at":{"type":"string","format":"date-time"},"pushed":{"type":"string","format":"date-time"},"floor":{"type":["number","null"]},"limit":{"type":["integer","null"]},"label":{"type":["string","null"]},"on":{"type":["boolean","null"]},"reason":{"type":"string","enum":["tag-gone","retention"]}},"required":["digest","pushed"]}}} diff --git a/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/a-registry/fixtures/states.json b/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/a-registry/fixtures/states.json new file mode 100644 index 000000000..e68231540 --- /dev/null +++ b/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/a-registry/fixtures/states.json @@ -0,0 +1,3 @@ +[{"name":"an ask","of":"state:asks/.","given":{"state":"open","since":3}}, + {"name":"a finished ask","of":"state:asks/..done","given":{"outcome":"refused"}}, + {"name":"the defaults","of":"state:asks/defaults","given":{"any":"thing"}}] diff --git a/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/a-registry/seat.json b/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/a-registry/seat.json new file mode 100644 index 000000000..42259601e --- /dev/null +++ b/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/a-registry/seat.json @@ -0,0 +1,12 @@ +{"format":1,"seat":"a-registry","scope":"mesh","grain":"whole","domain":"Module","decision":"ADR 1","includes":["registry"], + "verbs":[{"name":"hold","input":{"type":"object"},"answer":{"type":"object"}}], + "family":{"name":"a-registry.events","max-age-days":7}, + "states":[ + {"name":"asks","template":"state","description":"the asks held, one key per asker and ask", + "key-forms":[ + {"key":".","value":{"type":"object","properties":{"state":{"type":"string"},"since":{"type":"integer"}},"required":["state"]}}, + {"key":"..done","value":{"type":"object","properties":{"outcome":{"type":"string"}},"required":["outcome"]}}, + {"key":"..","value":{"type":"object","description":"a part named key beside the parser's own parameter"}}, + {"key":"defaults","value":{"type":"object","description":"free"}}]}, + {"name":"local-name","template":"events","max-age-days":3, + "key-forms":[{"key":"seen.","value":{"type":"string"}}]}]} diff --git a/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/b-registry/seat.json b/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/b-registry/seat.json new file mode 100644 index 000000000..984e450ac --- /dev/null +++ b/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/b-registry/seat.json @@ -0,0 +1,3 @@ +{"format":1,"seat":"b-registry","scope":"mesh","grain":"whole","domain":"Module","decision":"ADR 1","includes":["registry"], + "verbs":[{"name":"hold","input":{"type":"object"},"answer":{"type":"object"}}], + "family":{"name":"b-registry.events","max-age-days":7}} diff --git a/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/one-store/seat.json b/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/one-store/seat.json new file mode 100644 index 000000000..f742e27fa --- /dev/null +++ b/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/one-store/seat.json @@ -0,0 +1 @@ +{"format":1,"seat":"one-store","scope":"mesh","grain":"seat-list","domain":"Module","decision":"ADR 1","verbs":[{"name":"query","input":{"type":"object"}}]} diff --git a/modules/mesh-build-go/buildstep/seatgen/tsgen.go b/modules/mesh-build-go/buildstep/seatgen/tsgen.go new file mode 100644 index 000000000..17e58563a --- /dev/null +++ b/modules/mesh-build-go/buildstep/seatgen/tsgen.go @@ -0,0 +1,232 @@ +package seatgen + +import ( + "bytes" + "encoding/json" + "fmt" + "strconv" + "strings" + + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +const tsHeader = "// Code generated by seatgen from the seat definitions; DO NOT EDIT.\n\n" + +// TSSeatDir is where a seat defined whole has its npm package (ADR 0625 §1): the indexer counts the npm package a +// lock resolves, so each such seat is a package of its own and a module is rebuilt only for the seats it uses. +func TSSeatDir(seat string) string { return "ts/seats/" + pkgName(seat) } + +// TSSeatPackage is the npm package name of a seat defined whole, beside the seat set's package root. +func TSSeatPackage(root, seat string) string { return root + "-" + seat } + +// tsConfig type-checks the package's sources and writes nothing: the package ships its TypeScript, which its importers +// compile or bundle, so it has no build script and needs no compiler of its own (F7: a generated package has no +// dependency, and its lock, which the build module requires of a package artifact, holds none). +const tsConfig = `{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} +` + +// tsPackage writes one npm package's package.json, its dependency-free package-lock.json and its tsconfig.json at dir. +func tsPackage(out map[string][]byte, dir, name, description string) { + pkg := map[string]any{ + "name": name, "version": "0.1.0", "type": "module", + "description": description, + "exports": map[string]any{".": "./src/index.ts"}, + "types": "./src/index.ts", + "files": []any{"src"}, + } + raw, _ := json.MarshalIndent(pkg, "", " ") + out[dir+"/package.json"] = append(raw, '\n') + lock := map[string]any{ + "name": name, "version": "0.1.0", "lockfileVersion": 3, "requires": true, + "packages": map[string]any{"": map[string]any{"name": name, "version": "0.1.0"}}, + } + raw, _ = json.MarshalIndent(lock, "", " ") + out[dir+"/package-lock.json"] = append(raw, '\n') + out[dir+"/tsconfig.json"] = []byte(tsConfig) +} + +// tsFiles writes the npm side: one package per seat defined whole at TSSeatDir (ADR 0625 §1), holding the parts it includes whole and importing nothing outside it. +func tsFiles(m *Module, out map[string][]byte) error { + partTypes := map[string]*typer{} + partFiles := map[string][]byte{} + for _, name := range sortedKeys(m.Parts) { + p := m.Parts[name] + d := seatdef.Definition{Grain: seatdef.Whole, Verbs: p.Verbs, Events: p.Events, Defs: p.Defs} + tp, err := declareAll(d, nil) + if err != nil { + return err + } + partTypes[name] = tp + var b bytes.Buffer + b.WriteString(tsHeader) + fmt.Fprintf(&b, "// The part %q several seats include whole: %s\n\n", p.Part, oneLine(p.Description)) + tsNames(&b, "verbs", seatdef.VerbNames(p.Verbs)) + tsNames(&b, "events", seatdef.MessageNames(p.Events)) + tsTypes(&b, tp) + partFiles[name] = b.Bytes() + } + for _, name := range m.Order { + d := m.Seats[name] + if d.Grain != seatdef.Whole { + continue + } + dir := TSSeatDir(name) + tsPackage(out, dir, TSSeatPackage(m.TSPackage, name), fmt.Sprintf("The contract of the seat %s, generated from its definition.", name)) + raw := m.Raw[name] + fromPart := map[string]string{} + for _, inc := range raw.Includes { + for _, t := range partTypes[inc].types { + fromPart[t.name] = inc + } + out[dir+"/src/parts/"+inc+".ts"] = partFiles[inc] + } + tp, err := declareAll(d, fromPart) + if err != nil { + return err + } + var b bytes.Buffer + b.WriteString(tsHeader) + fmt.Fprintf(&b, "// The contract of the seat %q, defined whole (seats/%s/seat.json): %s\n\n", d.Seat, d.Seat, oneLine(d.Description)) + for _, inc := range raw.Includes { + fmt.Fprintf(&b, "import type * as %s from \"./parts/%s.js\";\n\n", tsName(inc), inc) + } + fmt.Fprintf(&b, "export const seat = %q;\n\n", d.Seat) + tsNames(&b, "verbs", seatdef.VerbNames(d.Verbs)) + tsNames(&b, "accepts", seatdef.MessageNames(d.Accepts)) + tsNames(&b, "events", seatdef.MessageNames(d.Events)) + if f := d.Family; f != nil { + fmt.Fprintf(&b, "export const family = { name: %q, maxAgeDays: %d", f.Name, f.MaxAgeDays) + b.WriteString(" } as const;\n\n") + } + for _, e := range d.Events { + subj := seatdef.EventSubject(d.Seat, e.Name) + if e.ByCaller { + fmt.Fprintf(&b, "export function %sSubject(caller: string): string {\n return %q + caller;\n}\n\n", tsName(e.Name), subj+".") + } else { + fmt.Fprintf(&b, "export const %sSubject = %q;\n\n", tsName(e.Name), subj) + } + } + for _, a := range d.Accepts { + subj := seatdef.AcceptSubject(d.Seat, a.Name) + if a.ByCaller { + fmt.Fprintf(&b, "export function %sSubject(caller: string): string {\n return %q + caller;\n}\n\n", tsName(a.Name), subj+".") + } else { + fmt.Fprintf(&b, "export const %sSubject = %q;\n\n", tsName(a.Name), subj) + } + } + if len(d.HolderEvents) > 0 { + tsNames(&b, "holderEvents", holderNames(d.HolderEvents)) + for _, e := range d.HolderEvents { + before, after, _ := strings.Cut(e.Subject, "") + fmt.Fprintf(&b, "export function %sHolderSubject(holder: string): string {\n return %q + holder + %q;\n}\n\n", + tsName(e.Name), before, after) + } + } + if len(d.States) > 0 { + if err := tsStates(&b, d.States); err != nil { + return err + } + } + for _, n := range sortedKeys(fromPart) { + fmt.Fprintf(&b, "export type %s = %s.%s;\n", n, tsName(fromPart[n]), n) + } + if len(fromPart) > 0 { + b.WriteString("\n") + } + tsTypes(&b, tp) + out[dir+"/src/index.ts"] = b.Bytes() + } + return nil +} + +func holderNames(es []seatdef.HolderEvent) []string { + out := make([]string, len(es)) + for i, e := range es { + out[i] = e.Name + } + return out +} + +func tsNames(b *bytes.Buffer, what string, names []string) { + if len(names) == 0 { + return + } + fmt.Fprintf(b, "export const %s = {\n", what) + for _, n := range names { + fmt.Fprintf(b, " %s: %q,\n", tsName(n), n) + } + b.WriteString("} as const;\n\n") +} + +func tsTypes(b *bytes.Buffer, tp *typer) { + for _, t := range tp.types { + if d := strings.TrimSpace(t.desc); d != "" { + fmt.Fprintf(b, "/** %s */\n", oneLine(d)) + } + if t.t.kind != "struct" { + fmt.Fprintf(b, "export type %s = %s;\n\n", t.name, tsType(t.t)) + continue + } + fmt.Fprintf(b, "export interface %s {\n", t.name) + for _, f := range t.t.fields { + if f.desc != "" { + fmt.Fprintf(b, " /** %s */\n", oneLine(f.desc)) + } + opt := "?" + if f.required { + opt = "" + } + fmt.Fprintf(b, " %s%s: %s;\n", strconv.Quote(f.json), opt, tsType(f.t)) + } + b.WriteString("}\n\n") + } +} + +func tsType(t *typ) string { + if t.null { + n := *t + n.null = false + return tsType(&n) + " | null" + } + switch t.kind { + case "string": + if len(t.enum) > 0 { + parts := make([]string, len(t.enum)) + for i, e := range t.enum { + parts[i] = strconv.Quote(e) + } + return strings.Join(parts, " | ") + } + return "string" + case "time": + return "string" + case "integer", "number": + return "number" + case "boolean": + return "boolean" + case "object": + return "Record" + case "map": + return "Record" + case "slice": + el := tsType(t.elem) + if strings.Contains(el, "|") { + el = "(" + el + ")" + } + return el + "[]" + case "ref": + return t.ref + } + return "unknown" +} diff --git a/modules/mesh-build-go/buildstep/seatgen/types.go b/modules/mesh-build-go/buildstep/seatgen/types.go new file mode 100644 index 000000000..4b96bae42 --- /dev/null +++ b/modules/mesh-build-go/buildstep/seatgen/types.go @@ -0,0 +1,188 @@ +package seatgen + +import ( + "fmt" + "sort" + "strings" +) + +// A typ is one JSON Schema read as a type both languages write: the subset the definitions use (objects with +// properties, maps, arrays, strings, integers, numbers, booleans, references to a definition's own types). Anything +// else is `any`, so a schema the generator does not understand still decodes. +type typ struct { + kind string // string, time (a string of format date-time), integer, number, boolean, any, map, slice, struct, ref, object (free) + elem *typ // a map's value or a slice's item + ref string // a referenced type's name + fields []field + enum []string + null bool // a scalar typed ["T","null"]: Go writes *T, TypeScript T | null, so a declared zero survives +} + +type field struct { + json string + required bool + desc string + t *typ +} + +// named is one type a seat's package declares: a struct, or an alias of another. +type named struct { + name string + desc string + t *typ +} + +// typer reads schemas into named types, naming each inline object by where it sits. +type typer struct { + types []named + taken map[string]bool +} + +func newTyper() *typer { return &typer{taken: map[string]bool{}} } + +// declare reads schema s as the type called name and declares it. +func (tp *typer) declare(name, desc string, s map[string]any) error { + if tp.taken[name] { + return fmt.Errorf("two types would be called %s", name) + } + tp.taken[name] = true + t, err := tp.read(name, s, true) + if err != nil { + return fmt.Errorf("%s: %w", name, err) + } + if desc == "" { + desc, _ = s["description"].(string) + } + tp.types = append(tp.types, named{name: name, desc: desc, t: t}) + return nil +} + +func (tp *typer) read(where string, s map[string]any, top bool) (*typ, error) { + if s == nil { + return &typ{kind: "any"}, nil + } + if r, ok := s["$ref"].(string); ok { + n, found := strings.CutPrefix(r, "#/defs/") + if !found || n == "" { + return nil, fmt.Errorf("$ref %q is not #/defs/", r) + } + return &typ{kind: "ref", ref: n}, nil + } + t, _ := s["type"].(string) + if list, ok := s["type"].([]any); ok { + // ["T","null"] with T a scalar is a nullable T; any other list of types stays `any`. + var base string + null := false + for _, v := range list { + switch str, _ := v.(string); str { + case "null": + null = true + case "number", "integer", "string", "boolean": + if base != "" { + base = "?" + } else { + base = str + } + default: + base = "?" + } + } + if null && base != "" && base != "?" { + inner := make(map[string]any, len(s)) + for k, v := range s { + inner[k] = v + } + inner["type"] = base + out, err := tp.read(where, inner, top) + if err != nil { + return nil, err + } + if out.kind == "time" { + return out, nil + } + out.null = true + return out, nil + } + } + switch t { + case "string": + out := &typ{kind: "string"} + if f, _ := s["format"].(string); f == "date-time" { + // A moment: Go writes time.Time (RFC 3339 on the wire), TypeScript the string (ADR 0634). + return &typ{kind: "time"}, nil + } + if e, ok := s["enum"].([]any); ok { + for _, v := range e { + if str, ok := v.(string); ok { + out.enum = append(out.enum, str) + } + } + } + return out, nil + case "integer", "number", "boolean": + return &typ{kind: t}, nil + case "array": + items, _ := s["items"].(map[string]any) + el, err := tp.inline(where+"Item", items) + if err != nil { + return nil, err + } + return &typ{kind: "slice", elem: el}, nil + case "object": + props, hasProps := s["properties"].(map[string]any) + if !hasProps { + if ap, ok := s["additionalProperties"].(map[string]any); ok { + el, err := tp.inline(where+"Value", ap) + if err != nil { + return nil, err + } + return &typ{kind: "map", elem: el}, nil + } + return &typ{kind: "object"}, nil + } + required := map[string]bool{} + switch r := s["required"].(type) { + case []any: + for _, v := range r { + if str, ok := v.(string); ok { + required[str] = true + } + } + case []string: + for _, v := range r { + required[v] = true + } + } + names := make([]string, 0, len(props)) + for k := range props { + names = append(names, k) + } + sort.Strings(names) + out := &typ{kind: "struct"} + for _, k := range names { + ps, _ := props[k].(map[string]any) + ft, err := tp.inline(where+ident(k), ps) + if err != nil { + return nil, err + } + desc, _ := ps["description"].(string) + out.fields = append(out.fields, field{json: k, required: required[k], desc: desc, t: ft}) + } + return out, nil + } + return &typ{kind: "any"}, nil +} + +// inline is a nested schema's type: an object with properties becomes a named type of its own, called where. +func (tp *typer) inline(where string, s map[string]any) (*typ, error) { + if s != nil { + if _, props := s["properties"].(map[string]any); props && s["type"] == "object" { + desc, _ := s["description"].(string) + if err := tp.declare(where, desc, s); err != nil { + return nil, err + } + return &typ{kind: "ref", ref: where}, nil + } + } + return tp.read(where, s, false) +} diff --git a/modules/mesh-build-go/buildstep/step.go b/modules/mesh-build-go/buildstep/step.go index a265184e9..c145958ff 100644 --- a/modules/mesh-build-go/buildstep/step.go +++ b/modules/mesh-build-go/buildstep/step.go @@ -12,11 +12,11 @@ import ( "log" "time" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" + buildrun "git.novox.be/novox/mesh-delivery/sdk/buildrun" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) -// Put writes one key of a state: stdio.State(delivery.StepsState).Put's shape (go/state.go). +// Put writes one key of a state: the steps state.Put's shape (go/state.go). type Put func(key string, value any) (uint64, error) // EndAttempts and EndRetry bound how a step's end is written: a write that fails is tried again, since the end @@ -43,20 +43,20 @@ func Start(ctx context.Context, put Put, node, tool string, run func(ctx context } // begin writes the step running under id, owned by this process (Owner). -func begin(put Put, node, tool, id, what string) (delivery.Step, string, error) { - step := delivery.Step{ID: id, Tool: tool, Node: node, State: delivery.StepRunning, Started: now().UTC(), What: what, Owner: sdkstep.Owner} - key, err := delivery.StepKey(node, step.ID) +func begin(put Put, node, tool, id, what string) (sdkstep.Step, string, error) { + step := sdkstep.Step{ID: id, Tool: tool, Node: node, State: sdkstep.StepRunning, Started: now().UTC(), What: what, Owner: buildrun.Owner} + key, err := sdkstep.StepKey(node, step.ID) if err != nil { - return delivery.Step{}, "", err + return sdkstep.Step{}, "", err } if err := write(put, key, step); err != nil { - return delivery.Step{}, "", fmt.Errorf("buildstep: writing the running step %s: %w", key, err) + return sdkstep.Step{}, "", fmt.Errorf("buildstep: writing the running step %s: %w", key, err) } return step, key, nil } // end runs run and writes the step's end. -func end(ctx context.Context, put Put, key string, step delivery.Step, run func(ctx context.Context) (any, error)) { +func end(ctx context.Context, put Put, key string, step sdkstep.Step, run func(ctx context.Context) (any, error)) { result, err := protect(ctx, run) _ = finish(put, key, step, result, err, log.Printf) // finish says a refused end itself } @@ -66,23 +66,29 @@ func end(ctx context.Context, put Put, key string, step delivery.Step, run func( // refusal is logged and a small failed end without the result is written instead, naming the write's error, so the // step's reader learns it is over and why. finish answers an error only when no end could be written at all, and then // the step's ask is not acknowledged as done. -func finish(put Put, key string, step delivery.Step, result any, err error, logf func(format string, args ...any)) error { +func finish(put Put, key string, step sdkstep.Step, result any, err error, logf func(format string, args ...any)) error { step.Ended = now().UTC() switch ref := (*Refusal)(nil); { case err == nil: - step.State = delivery.StepPassed + step.State = sdkstep.StepPassed if result != nil { raw, merr := json.Marshal(result) if merr != nil { - step.State, step.Reason = delivery.StepFailed, "the result is not JSON: "+merr.Error() + step.State, step.Reason = sdkstep.StepFailed, "the result is not JSON: "+merr.Error() } else { step.Result = raw } } case errors.As(err, &ref): - step.State, step.Reason = delivery.StepRefused, err.Error() + step.State, step.Reason = sdkstep.StepRefused, err.Error() default: - step.State, step.Reason = delivery.StepFailed, err.Error() + step.State, step.Reason = sdkstep.StepFailed, err.Error() + // A failed step may answer what it found on the way (a build's out-of-step files): kept on its end. + if result != nil { + if raw, merr := json.Marshal(result); merr == nil { + step.Result = raw + } + } } werr := writeEnd(put, key, step) if werr == nil { @@ -96,7 +102,7 @@ func finish(put Put, key string, step delivery.Step, result any, err error, logf if step.Reason != "" { why = fmt.Sprintf("the step ended %s (%s), and its end could not be written: %v", ended, step.Reason, werr) } - step.State, step.Reason, step.Result = delivery.StepFailed, why, nil + step.State, step.Reason, step.Result = sdkstep.StepFailed, why, nil if ferr := writeEnd(put, key, step); ferr != nil { if logf != nil { logf("the failed end of the step %s could not be written either: %v", key, ferr) @@ -107,7 +113,7 @@ func finish(put Put, key string, step delivery.Step, result any, err error, logf } // writeEnd writes the step's end, trying EndAttempts times. -func writeEnd(put Put, key string, step delivery.Step) error { +func writeEnd(put Put, key string, step sdkstep.Step) error { for attempt := 1; ; attempt++ { werr := write(put, key, step) if werr == nil || attempt >= EndAttempts { @@ -127,8 +133,8 @@ func protect(ctx context.Context, run func(ctx context.Context) (any, error)) (r return run(ctx) } -func write(put Put, key string, step delivery.Step) error { - raw, err := delivery.EncodeStep(step) +func write(put Put, key string, step sdkstep.Step) error { + raw, err := sdkstep.EncodeStep(step) if err != nil { return err } diff --git a/modules/mesh-build-go/buildstep/step_test.go b/modules/mesh-build-go/buildstep/step_test.go index f802872d5..a2a19cd2c 100644 --- a/modules/mesh-build-go/buildstep/step_test.go +++ b/modules/mesh-build-go/buildstep/step_test.go @@ -4,25 +4,25 @@ import ( "context" "encoding/json" "errors" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" "sync" "testing" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + buildrun "git.novox.be/novox/mesh-delivery/sdk/buildrun" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // keys is a steps state in memory: every value put, in order, per key, and a signal for each end. type keys struct { mu sync.Mutex - puts map[string][]delivery.Step + puts map[string][]sdkstep.Step ended chan string } -func newKeys() *keys { return &keys{puts: map[string][]delivery.Step{}, ended: make(chan string, 16)} } +func newKeys() *keys { return &keys{puts: map[string][]sdkstep.Step{}, ended: make(chan string, 16)} } func (k *keys) put(key string, value any) (uint64, error) { - step, err := delivery.DecodeStep(value.(json.RawMessage)) + step, err := sdkstep.DecodeStep(value.(json.RawMessage)) if err != nil { return 0, err } @@ -30,13 +30,13 @@ func (k *keys) put(key string, value any) (uint64, error) { k.puts[key] = append(k.puts[key], step) n := uint64(len(k.puts[key])) k.mu.Unlock() - if step.State != delivery.StepRunning { + if step.State != sdkstep.StepRunning { k.ended <- key } return n, nil } -func (k *keys) last(key string) delivery.Step { +func (k *keys) last(key string) sdkstep.Step { k.mu.Lock() defer k.mu.Unlock() s := k.puts[key] @@ -72,8 +72,8 @@ func TestStartAnswersAtOnceWithTheStepRunning(t *testing.T) { } s := k.last("ace." + id) // 1890/AC5: the running key names this process as its owner. - if s.State != delivery.StepRunning || s.Tool != "fetch" || s.Node != "ace" || s.ID != id || s.Started.IsZero() || s.Owner != sdkstep.Owner || sdkstep.Owner == "" { - t.Errorf("step = %+v, want owner %q", s, sdkstep.Owner) + if s.State != sdkstep.StepRunning || s.Tool != "fetch" || s.Node != "ace" || s.ID != id || s.Started.IsZero() || s.Owner != buildrun.Owner || buildrun.Owner == "" { + t.Errorf("step = %+v, want owner %q", s, buildrun.Owner) } } @@ -86,11 +86,11 @@ func TestStepEndsPassedFailedOrRefusedOnItsKey(t *testing.T) { reason string result string }{ - {"result", func(context.Context) (any, error) { return map[string]string{"sha": "abc"}, nil }, delivery.StepPassed, "", `{"sha":"abc"}`}, - {"error", func(context.Context) (any, error) { return nil, errors.New("exit 2") }, delivery.StepFailed, "exit 2", ""}, + {"result", func(context.Context) (any, error) { return map[string]string{"sha": "abc"}, nil }, sdkstep.StepPassed, "", `{"sha":"abc"}`}, + {"error", func(context.Context) (any, error) { return nil, errors.New("exit 2") }, sdkstep.StepFailed, "exit 2", ""}, {"refusal", func(context.Context) (any, error) { return nil, &Refusal{Text: "go 1.27.1 installed, the recipe builds with go 1.27.0", Want: "1.27.0", Have: "1.27.1"} - }, delivery.StepRefused, "go 1.27.1 installed, the recipe builds with go 1.27.0", ""}, + }, sdkstep.StepRefused, "go 1.27.1 installed, the recipe builds with go 1.27.0", ""}, } { t.Run(c.name, func(t *testing.T) { k := newKeys() @@ -102,7 +102,7 @@ func TestStepEndsPassedFailedOrRefusedOnItsKey(t *testing.T) { t.Fatalf("ended key %q", got) } s := k.last("ace." + id) - if s.State != c.state || s.Reason != c.reason || string(s.Result) != c.result || s.Ended.IsZero() || s.Owner != sdkstep.Owner { + if s.State != c.state || s.Reason != c.reason || string(s.Result) != c.result || s.Ended.IsZero() || s.Owner != buildrun.Owner { t.Errorf("step = %+v", s) } }) @@ -115,7 +115,7 @@ func TestAPanicEndsTheStepFailed(t *testing.T) { id, _ := Start(context.Background(), k.put, "ace", "build", func(context.Context) (any, error) { panic("boom") }) k.waitEnd(t) s := k.last("ace." + id) - if s.State != delivery.StepFailed || s.Reason != "the step panicked: boom" { + if s.State != sdkstep.StepFailed || s.Reason != "the step panicked: boom" { t.Errorf("step = %+v", s) } } @@ -134,7 +134,7 @@ func TestTwoIDsInOneMillisecondDiffer(t *testing.T) { t.Fatalf("id %q handed out twice", id) } seen[id] = true - if _, err := delivery.StepKey("ace", id); err != nil { + if _, err := sdkstep.StepKey("ace", id); err != nil { t.Errorf("id %q: %v", id, err) } } diff --git a/modules/mesh-build-go/buildstep/steptool.go b/modules/mesh-build-go/buildstep/steptool.go index be1a1eef6..92e8eef02 100644 --- a/modules/mesh-build-go/buildstep/steptool.go +++ b/modules/mesh-build-go/buildstep/steptool.go @@ -1,14 +1,14 @@ package buildstep import ( - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" + buildrun "git.novox.be/novox/mesh-delivery/sdk/buildrun" stdio "git.novox.be/novox/mesh-sdk/go" ) // StepEndTool is the module's step-end tool, named name (build_go_step_end): it ends one step left running, with a // reason, refusing one this process started. p may be nil. -func StepEndTool(name string, node string, scan sdkstep.Scan, p *Puller) stdio.Tool { +func StepEndTool(name string, node string, scan buildrun.Scan, p *Puller) stdio.Tool { return stdio.Tool{Name: name, Description: "End one step of this machine that is stuck in state running (for instance left by a restart of the module) " + "as failed, with the reason given; its asker then sees it over, so ending an ask still in its stream fails its asker. " + @@ -31,6 +31,6 @@ func StepEndTool(name string, node string, scan sdkstep.Scan, p *Puller) stdio.T return false } } - return sdkstep.EndOne(scan, node, id, why, live) + return buildrun.EndOne(scan, node, id, why, live) }} } diff --git a/modules/mesh-build-go/buildstep/tap.go b/modules/mesh-build-go/buildstep/tap.go index 7151ece16..2c3d5e488 100644 --- a/modules/mesh-build-go/buildstep/tap.go +++ b/modules/mesh-build-go/buildstep/tap.go @@ -5,7 +5,7 @@ package buildstep // earlier file, which is what node's default reporter left the first fifteen lines of. import ( - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "regexp" "strings" ) diff --git a/modules/mesh-build-go/buildstep/trees.go b/modules/mesh-build-go/buildstep/trees.go index fb41c758b..1b9b0b910 100644 --- a/modules/mesh-build-go/buildstep/trees.go +++ b/modules/mesh-build-go/buildstep/trees.go @@ -10,7 +10,8 @@ import ( "path/filepath" "strings" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -26,7 +27,7 @@ var lockFiles = []string{"go.sum", "package-lock.json"} func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, commit string) (map[string]sdkstep.ModuleTree, error) { r := run(ctx, env, "git", "-C", dir, "ls-files", "--", "module.json", "*/module.json") if r.Status != 0 || r.Err != "" { - return nil, fmt.Errorf("git ls-files: %s", forgekey.Said(r.Err+"\n"+r.Stderr)) + return nil, fmt.Errorf("git ls-files: %s", dforgekey.Said(r.Err+"\n"+r.Stderr)) } trees := map[string]sdkstep.ModuleTree{} for _, l := range strings.Split(r.Stdout, "\n") { @@ -40,7 +41,7 @@ func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, co } t := run(ctx, env, "git", "-C", dir, "rev-parse", spec) if t.Status != 0 || t.Err != "" { - return nil, fmt.Errorf("git rev-parse %s: %s", spec, forgekey.Said(t.Err+"\n"+t.Stderr)) + return nil, fmt.Errorf("git rev-parse %s: %s", spec, dforgekey.Said(t.Err+"\n"+t.Stderr)) } mt := sdkstep.ModuleTree{Tree: strings.TrimSpace(t.Stdout)} raw, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(l))) @@ -86,7 +87,7 @@ func narrow(ctx context.Context, run forgekey.Runner, env []string, dir, commit, for _, round := range unnested(full) { r := run(ctx, env, "git", append([]string{"-C", dir, "ls-tree", "-z", commit, "--"}, round...)...) if r.Status != 0 || r.Err != "" { - return fmt.Errorf("git ls-tree %s of the module %s: %s", commit, mdir, forgekey.Said(r.Err+"\n"+r.Stderr)) + return fmt.Errorf("git ls-tree %s of the module %s: %s", commit, mdir, dforgekey.Said(r.Err+"\n"+r.Stderr)) } for _, l := range strings.Split(r.Stdout, "\x00") { meta, name, ok := strings.Cut(l, "\t") diff --git a/modules/mesh-build-go/buildstep/trees_integration_test.go b/modules/mesh-build-go/buildstep/trees_integration_test.go index 6bf462db6..0cbc52f6b 100644 --- a/modules/mesh-build-go/buildstep/trees_integration_test.go +++ b/modules/mesh-build-go/buildstep/trees_integration_test.go @@ -4,7 +4,7 @@ package buildstep import ( "context" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "reflect" "strings" "testing" @@ -75,3 +75,44 @@ func TestAGoOnlyCommitLeavesTheNamedObjectsEqualOnRealGit(t *testing.T) { t.Fatalf("a path and a path inside it: %v", nested.Paths) } } + +// D-I4 (ADR 0634 §8) on a real repository shaped like mesh-contracts: a root module naming the paths of each of its +// artifacts has the folders of the modules nested in it left out of its source, and a module in a folder is narrowed +// like a root one, its paths read from its own directory. A commit to a folder changes that folder's objects and +// none of the root's; a commit to the root's own package changes none of the folder's. +func TestARootModulesSourceLeavesOutItsNestedModuleFolders(t *testing.T) { + repo := t.TempDir() + realGit(t, repo, "init", "-q", "-b", "main") + writeFile(t, repo, "module.json", `{"module":"root","version":"1","build":{"artifacts":[{"name":"go-core","kind":"package","language":"go","paths":["go/seats/core"]}]}}`) + writeFile(t, repo, "go/seats/core/go.mod", "module example.org/c/go/seats/core\n") + writeFile(t, repo, "domain/module.json", `{"module":"domain","version":"1","build":{"artifacts":[{"name":"go-one","kind":"package","language":"go","paths":["go/seats/one"]},{"name":"go-oneclient","kind":"package","language":"go","paths":["go/clients/oneclient"]}]}}`) + writeFile(t, repo, "domain/seats/one/seat.json", "{}\n") + writeFile(t, repo, "domain/go/seats/one/go.mod", "module example.org/c/domain/go/seats/one\n") + writeFile(t, repo, "domain/go/clients/oneclient/go.mod", "module example.org/c/domain/go/clients/oneclient\n") + commit := func(msg string) map[string]sdkstep.ModuleTree { + realGit(t, repo, "add", "-A") + realGit(t, repo, "commit", "-q", "-m", msg) + got, err := ModuleTrees(context.Background(), forgekey.ExecRunner, nil, repo, realGit(t, repo, "rev-parse", "HEAD")) + if err != nil { + t.Fatal(err) + } + return got + } + first := commit("first") + if got := len(first["."].Paths); got != 1 { + t.Fatalf("the root names %v: only its own package, never a nested module's folder", first["."].Paths) + } + if got := first["domain"].Paths; len(got) != 2 || got["go/seats/one"] == "" || got["go/clients/oneclient"] == "" { + t.Fatalf("the folder module's named objects %v, want its two packages read from its own directory", got) + } + writeFile(t, repo, "domain/go/seats/one/one.go", "package one\n") + folder := commit("folder") + if !reflect.DeepEqual(folder["."].Paths, first["."].Paths) || reflect.DeepEqual(folder["domain"].Paths, first["domain"].Paths) { + t.Fatalf("a commit to the folder: root %v (was %v), folder %v (was %v)", folder["."].Paths, first["."].Paths, folder["domain"].Paths, first["domain"].Paths) + } + writeFile(t, repo, "go/seats/core/core.go", "package core\n") + root := commit("root") + if reflect.DeepEqual(root["."].Paths, folder["."].Paths) || !reflect.DeepEqual(root["domain"].Paths, folder["domain"].Paths) { + t.Fatalf("a commit to the root's package: root %v (was %v), folder %v (was %v)", root["."].Paths, folder["."].Paths, root["domain"].Paths, folder["domain"].Paths) + } +} diff --git a/modules/mesh-build-go/buildstep/trees_test.go b/modules/mesh-build-go/buildstep/trees_test.go index 2747a9dbf..6b83536cf 100644 --- a/modules/mesh-build-go/buildstep/trees_test.go +++ b/modules/mesh-build-go/buildstep/trees_test.go @@ -4,7 +4,7 @@ import ( "context" "crypto/sha256" "encoding/hex" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "os" "path/filepath" "reflect" diff --git a/modules/mesh-build-go/cmd/mesh-build-go/announce.go b/modules/mesh-build-go/cmd/mesh-build-go/announce.go index 2163bca16..3550c87d1 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/announce.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/announce.go @@ -15,6 +15,8 @@ var Kept = []announce.State{ {Name: "keys", Readable: true}, {Name: "steps", Readable: true}, {Name: "pauses", Readable: true}, + // The condition keeper raises a first view of the indexer's manifests it could not get (ADR 0615 §6) from this. + {Name: "findings", Readable: true}, } // keepStates announces Kept as this module's (stdio.Keeps): main calls it before it serves. diff --git a/modules/mesh-build-go/cmd/mesh-build-go/archive.go b/modules/mesh-build-go/cmd/mesh-build-go/archive.go index a25d64771..5c1388153 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/archive.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/archive.go @@ -77,7 +77,7 @@ func (m *Module) buildArchive(ctx context.Context, workspace, module, name, dir if !within(realDir, realRoot) { return nil, fmt.Errorf("the archive %s is taken from %q, which lies outside the module: it resolves to %s, and the module is %s", art.Name, art.From, realRoot, realDir) } - return m.packInto(ctx, workspace, module, name, art.Name, root) + return m.packInto(ctx, workspace, module, name, art.Name, "archive", root) } // pinnedArchive fetches the file an artifact is pinned to, checks it against the pin, lays it out as the artifact says @@ -111,7 +111,7 @@ func (m *Module) pinnedArchive(ctx context.Context, workspace, module, name stri } else if err := unpackUpstream(file, laid, art.Within); err != nil { return nil, fmt.Errorf("%s: %s (%s): %w", art.Name, art.URL, art.Version, err) } - return m.packInto(ctx, workspace, module, name, art.Name, laid) + return m.packInto(ctx, workspace, module, name, art.Name, "archive", laid) } // pinnedFile is the path of the pinned file in the workspace: kept from an earlier build when its bytes are still the diff --git a/modules/mesh-build-go/cmd/mesh-build-go/archive_test.go b/modules/mesh-build-go/cmd/mesh-build-go/archive_test.go index 0b0930fc7..9df531908 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/archive_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/archive_test.go @@ -16,7 +16,7 @@ import ( "strings" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // Issue 1589: the build step packs an archive artifact (novox/hq ADR 0478 §1), as the build agent packs it @@ -146,7 +146,7 @@ func pinnedModule(t *testing.T, path, sha, extra string) (*Module, *keptStates) func TestAnArchiveOfADirectoryHasTheBuildAgentsDigest(t *testing.T) { m, states, calls := archiveModule(t, "") res, st := runBuild(t, m, states, "files") - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("the step ended %s: %s", st.State, st.Reason) } sameAsBuildAgent(t, res, buildAgentDirectoryStream, buildAgentDirectoryDigest) @@ -177,7 +177,7 @@ func TestAnArchiveFromOutsideTheModuleOrMissingFailsTheStep(t *testing.T) { "nothing": "nothing", } { m, states, _ := archiveModule(t, `{"module":"demo","version":"1","build":{"artifacts":[{"name":"files","kind":"archive","from":"`+from+`"}]}}`) - if _, st := runBuild(t, m, states, "files"); st.State != delivery.StepFailed || !strings.Contains(st.Reason, want) { + if _, st := runBuild(t, m, states, "files"); st.State != sdkstep.StepFailed || !strings.Contains(st.Reason, want) { t.Errorf("from %q: %s %q", from, st.State, st.Reason) } } @@ -193,7 +193,7 @@ func pinned(url, sha, extra string) string { func TestAPinnedArchiveIsUnpackedWithinAndHasTheBuildAgentsDigest(t *testing.T) { m, states := pinnedModule(t, "/release.tar.gz", releaseSHA256, `,"within":"release/app"`) res, st := runBuild(t, m, states, "files") - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("%s %q", st.State, st.Reason) } sameAsBuildAgent(t, res, buildAgentWithinStream, buildAgentWithinDigest) @@ -202,7 +202,7 @@ func TestAPinnedArchiveIsUnpackedWithinAndHasTheBuildAgentsDigest(t *testing.T) func TestAPinnedFileKeptWholeHasTheBuildAgentsDigest(t *testing.T) { m, states := pinnedModule(t, "/release.tar.gz", releaseSHA256, `,"file":"tool.bin"`) res, st := runBuild(t, m, states, "files") - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("%s %q", st.State, st.Reason) } sameAsBuildAgent(t, res, buildAgentFileStream, buildAgentFileDigest) @@ -213,7 +213,7 @@ func TestAPinnedFileOfAnotherDigestIsRefused(t *testing.T) { wrong := strings.Repeat("ab", 32) m, states := pinnedModule(t, "/release.tar.gz", wrong, `,"within":"release/app"`) _, st := runBuild(t, m, states, "files") - if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "sha256:"+wrong) || !strings.Contains(st.Reason, "sha256:"+releaseSHA256) { + if st.State != sdkstep.StepFailed || !strings.Contains(st.Reason, "sha256:"+wrong) || !strings.Contains(st.Reason, "sha256:"+releaseSHA256) { t.Errorf("the step ended %s: %q", st.State, st.Reason) } if entries, _ := os.ReadDir(filepath.Join(m.Work, "ws1", "artifacts")); len(entries) != 0 { @@ -223,7 +223,7 @@ func TestAPinnedFileOfAnotherDigestIsRefused(t *testing.T) { func TestAPinnedFileTheServerRefusesFailsTheStep(t *testing.T) { m, states := pinnedModule(t, "/gone", releaseSHA256, `,"file":"x"`) - if _, st := runBuild(t, m, states, "files"); st.State != delivery.StepFailed || !strings.Contains(st.Reason, "404") { + if _, st := runBuild(t, m, states, "files"); st.State != sdkstep.StepFailed || !strings.Contains(st.Reason, "404") { t.Errorf("the step ended %s: %q", st.State, st.Reason) } } @@ -231,7 +231,7 @@ func TestAPinnedFileTheServerRefusesFailsTheStep(t *testing.T) { // Entries outside `within` are refused, as the build agent refuses them. func TestAPinnedArchiveWithEntriesOutsideWithinIsRefused(t *testing.T) { m, states := pinnedModule(t, "/release.tar.gz", releaseSHA256, `,"within":"release/other"`) - if _, st := runBuild(t, m, states, "files"); st.State != delivery.StepFailed || !strings.Contains(st.Reason, "release/other") { + if _, st := runBuild(t, m, states, "files"); st.State != sdkstep.StepFailed || !strings.Contains(st.Reason, "release/other") { t.Errorf("the step ended %s: %q", st.State, st.Reason) } } @@ -239,7 +239,7 @@ func TestAPinnedArchiveWithEntriesOutsideWithinIsRefused(t *testing.T) { // Other kinds are still refused by name. func TestAnImageArtifactIsStillRefusedByName(t *testing.T) { m, states, _ := archiveModule(t, `{"module":"demo","version":"1","build":{"artifacts":[{"name":"files","kind":"image","from":"files"}]}}`) - if _, st := runBuild(t, m, states, "files"); st.State != delivery.StepFailed || !strings.Contains(st.Reason, "image") { + if _, st := runBuild(t, m, states, "files"); st.State != sdkstep.StepFailed || !strings.Contains(st.Reason, "image") { t.Errorf("the step ended %s: %q", st.State, st.Reason) } } @@ -259,7 +259,7 @@ func TestAnArchiveBehindASymlinkedParentIsRefused(t *testing.T) { t.Fatal(err) } _, st := runBuild(t, m, states, "files") - if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "outside") || !strings.Contains(st.Reason, outside) { + if st.State != sdkstep.StepFailed || !strings.Contains(st.Reason, "outside") || !strings.Contains(st.Reason, outside) { t.Errorf("the step ended %s: %q", st.State, st.Reason) } if entries, _ := os.ReadDir(filepath.Join(m.Work, "ws1", "artifacts")); len(entries) != 0 { @@ -288,7 +288,7 @@ func TestAnArchiveOfALinkedModuleDirectoryIsRefused(t *testing.T) { } } st := states.waitEnd(t) - if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "outside") || !strings.Contains(st.Reason, outside) { + if st.State != sdkstep.StepFailed || !strings.Contains(st.Reason, "outside") || !strings.Contains(st.Reason, outside) { t.Errorf("the step ended %s: %q", st.State, st.Reason) } if entries, _ := os.ReadDir(filepath.Join(m.Work, "ws1", "artifacts")); len(entries) != 0 { diff --git a/modules/mesh-build-go/cmd/mesh-build-go/asks.go b/modules/mesh-build-go/cmd/mesh-build-go/asks.go index 3ddcb7c43..c595f5dfb 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/asks.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/asks.go @@ -4,12 +4,15 @@ import ( "context" "encoding/json" "fmt" + "path/filepath" "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" + checkerseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker" + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildgo" + buildrun "git.novox.be/novox/mesh-delivery/sdk/buildrun" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" stdio "git.novox.be/novox/mesh-sdk/go" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" ) // **The module pulls its seat's asks** (novox/hq ADR 0525 §2-§6, ADR 0536 §4; issue 1887 A3go). It holds the seat @@ -23,7 +26,10 @@ const Seat = "node-build-go" // KeptState is the checker's kept verdicts, which a check ask takes for the packages whose digest has one (issue // 1887's design limit 1). -const KeptState = delivery.CheckerModule + ".kept" +const KeptState = checkerModule + "." + checkerseat.StateKept + +// checkerModule is the module that holds the seat mesh-delivery-checker, whose kept state a check ask reads. +const checkerModule = "mesh-delivery-checker" // askSteps are the module's step bodies for an ask, each holding its workspace while it runs, as a tool's does. func (m *Module) askSteps(steps []StepTool) buildstep.AskSteps { @@ -38,12 +44,13 @@ func (m *Module) askSteps(steps []StepTool) buildstep.AskSteps { return buildstep.AskSteps{ Fetch: byName["build_go_fetch"], Build: byName["build_go_build"], Digest: byName["build_go_digest"], Test: byName["build_go_test"], Imports: byName[ImportsTool], - Toolchain: func(ctx context.Context) (delivery.ToolchainUsed, error) { + Compare: buildstep.CompareStep(buildstep.GeneratedRootGo, func(ws string) string { return filepath.Join(m.Work, ws, "tree") }), + Toolchain: func(ctx context.Context) (sdkstep.ToolchainUsed, error) { installed, err := m.Probe(ctx) if err != nil { - return delivery.ToolchainUsed{}, err + return sdkstep.ToolchainUsed{}, err } - return delivery.ToolchainUsed{Version: installed, Recipe: m.Recipe.Version()}, nil + return sdkstep.ToolchainUsed{Version: installed, Recipe: m.Recipe.Version()}, nil }, Kept: m.KeptLookup, Has: m.Has, @@ -63,25 +70,22 @@ func (m *Module) keptValue(key string) (json.RawMessage, error) { // KeptLookup is whether the checker kept a verdict for the level and digest, and how many tests it counted. func (m *Module) KeptLookup(ctx context.Context, level, digest string) (int, bool, error) { - key, err := delivery.KeptKey(level, digest) - if err != nil { - return 0, false, err - } + key := checkerseat.KeptByLevelDigestKey(level, digest) raw, err := m.keptValue(key) if err != nil || raw == nil { return 0, false, err } - v, err := delivery.DecodeKept(raw) + v, err := checkerseat.DecodeKeptByLevelDigestValue(raw) if err != nil { return 0, false, fmt.Errorf("%s holds what is not a kept verdict: %w", key, err) } - return v.Tests, true, nil + return int(v.Tests), true, nil } // Puller is the module's pull of its seat's asks, on source (the runtime's seat pull when nil). func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep.Puller { if source == nil { - source = buildstep.SeatAsks{Language: delivery.AskGo} + source = buildstep.SeatAsks{Language: sdkstep.AskGo} } capacity := m.Capacity // The capacity's own change wakes the puller (issue 1887): the runtime's watch of the config value as mesh-config @@ -99,7 +103,7 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep capacity = buildstep.NodeCapacity(m.Settings, m.Node, m.Say) settings = m.Settings watch = buildstep.WatchConfigServed - cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[delivery.AskGo], m.Say) + cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[sdkstep.AskGo], m.Say) } ask := m.askSteps(steps) var load func(string) ([]byte, error) @@ -109,9 +113,9 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep return raw, err } } - return &buildstep.Puller{Source: source, Language: delivery.AskGo, Node: m.Node, Put: m.Steps, PausePut: m.Pauses, Load: load, + return &buildstep.Puller{Source: source, Language: sdkstep.AskGo, Node: m.Node, Put: m.Steps, PausePut: m.Pauses, Load: load, Capacity: capacity, SettingsFile: settings, WatchConfig: watch, CoresPerStep: cores, BuildHours: hours, Logf: m.Say, Has: m.Has, - Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { + Run: func(ctx context.Context, a sdkstep.StepAsk) (any, error) { if err := m.Check(ctx); err != nil { return nil, err } @@ -137,13 +141,13 @@ func (m *Module) SeatTools(p *buildstep.Puller) []stdio.Tool { const StepEndTool = "build_go_step_end" // endLeftRunning is the module's start sweep (issue 1890): it ends the steps of its node that a previous process left -// running (sdkstep.EndStaleAtStart; every step names its owner). main runs it once at the start, and the module's test +// running (buildrun.EndStaleAtStart; every step names its owner). main runs it once at the start, and the module's test // runs it through the conformance kit's left-running case. -func (m *Module) endLeftRunning(ctx context.Context, scan sdkstep.Scan, booted time.Time, logf func(string, ...any)) { - sdkstep.EndStaleAtStart(ctx, scan, m.Node, booted, logf) +func (m *Module) endLeftRunning(ctx context.Context, scan buildrun.Scan, booted time.Time, logf func(string, ...any)) { + buildrun.EndStaleAtStart(ctx, scan, m.Node, booted, logf) } // stepScan is the module's steps state as buildstep reads it. -func (m *Module) stepScan() sdkstep.Scan { - return sdkstep.ScanOf(stdio.State(delivery.StepsState)) +func (m *Module) stepScan() buildrun.Scan { + return buildrun.ScanOf(stdio.State(seat.StateSteps)) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/asks_integration_test.go b/modules/mesh-build-go/cmd/mesh-build-go/asks_integration_test.go index e3541c016..6272dbfb8 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/asks_integration_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/asks_integration_test.go @@ -12,7 +12,7 @@ import ( "github.com/nats-io/nats.go/jetstream" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/testbus" ) @@ -42,17 +42,17 @@ func TestTheModuleTakesADeliveryAskBeforeAnEarlierCheck(t *testing.T) { } ctx, stop := context.WithCancel(context.Background()) defer stop() - if err := buildstep.DeclareAskWorkers(ctx, js, delivery.AskGo, 30*time.Second); err != nil { + if err := buildstep.DeclareAskWorkers(ctx, js, sdkstep.AskGo, 30*time.Second); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = js.DeleteStream(context.Background(), "SEAT_NODE_BUILD_GO") }) check := deliveryAsk() - check.Kind = delivery.AskCheck - check.Build.Fetch["commit"] = checkCommit + check.Kind = sdkstep.AskCheck + check.Build.Fetch.Commit = checkCommit first := deliveryAsk() - for _, a := range []delivery.StepAsk{check, first} { - raw, _ := delivery.EncodeStepAsk(a) - subject, _ := delivery.AskSubject(a.Language, a.Kind) + for _, a := range []sdkstep.StepAsk{check, first} { + raw, _ := sdkstep.EncodeStepAsk(a) + subject, _ := sdkstep.AskSubject(a.Language, a.Kind) if _, err := js.Publish(ctx, subject, raw); err != nil { t.Fatal(err) } @@ -74,19 +74,19 @@ func TestTheModuleTakesADeliveryAskBeforeAnEarlierCheck(t *testing.T) { } } } - p := m.Puller(steps, &buildstep.JetStreamAsks{JS: js, Language: delivery.AskGo}) + p := m.Puller(steps, &buildstep.JetStreamAsks{JS: js, Language: sdkstep.AskGo}) p.PullWait = 200 * time.Millisecond go p.Pull(ctx) - var ran map[string][]delivery.Step + var ran map[string][]sdkstep.Step for deadline := time.Now().Add(10 * time.Second); ; time.Sleep(10 * time.Millisecond) { - ran = map[string][]delivery.Step{} + ran = map[string][]sdkstep.Step{} ended := 0 states.mu.Lock() - for _, a := range []delivery.StepAsk{first, check} { - key, _ := delivery.AskStepKey("ace", a.ID) - v := append([]delivery.Step(nil), states.steps[key]...) + for _, a := range []sdkstep.StepAsk{first, check} { + key, _ := sdkstep.AskStepKey("ace", a.ID) + v := append([]sdkstep.Step(nil), states.steps[key]...) ran[a.ID] = v - if len(v) > 0 && v[len(v)-1].State != delivery.StepRunning { + if len(v) > 0 && v[len(v)-1].State != sdkstep.StepRunning { ended++ } } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/asks_test.go b/modules/mesh-build-go/cmd/mesh-build-go/asks_test.go index 29c2100ff..7479b8015 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/asks_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/asks_test.go @@ -12,8 +12,7 @@ import ( "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) const askCommit = "0123456789abcdef0123456789abcdef01234567" @@ -34,11 +33,11 @@ func fakeSteps(calls *[]string) []StepTool { } } -func deliveryAsk() delivery.StepAsk { - return delivery.StepAsk{ID: sdkstep.NewID(), Kind: delivery.AskDelivery, Language: delivery.AskGo, Asker: "mesh-delivery-builder", - At: time.Now(), Build: &delivery.BuildJob{Module: "mesh-delivery-view", Path: "modules/mesh-delivery-view", Publish: true, - Fetch: map[string]any{"repository": "novox/mesh-delivery", "commit": askCommit}, - Artifacts: []delivery.AskArtifact{{Artifact: "program"}}}} +func deliveryAsk() sdkstep.StepAsk { + return sdkstep.StepAsk{ID: sdkstep.NewID(), Kind: sdkstep.AskDelivery, Language: sdkstep.AskGo, Asker: "mesh-delivery-builder", + At: time.Now(), Build: &sdkstep.BuildJob{Module: "mesh-delivery-view", Path: "modules/mesh-delivery-view", Publish: true, + Fetch: &sdkstep.Fetch{Repository: "novox/mesh-delivery", Commit: askCommit}, + Artifacts: []sdkstep.AskArtifact{{Artifact: "program"}}}} } // 1887/A3go: an ask pulled runs build_go_build after build_go_fetch, and the step key . names the @@ -58,13 +57,13 @@ func TestAPulledAskRunsTheBuildAndItsKeyNamesTheReference(t *testing.T) { ctx, stop := context.WithCancel(context.Background()) defer stop() go p.Pull(ctx) - key, _ := delivery.AskStepKey("ace", a.ID) - var step delivery.Step + key, _ := sdkstep.AskStepKey("ace", a.ID) + var step sdkstep.Step for deadline := time.Now().Add(30 * time.Second); ; time.Sleep(5 * time.Millisecond) { states.mu.Lock() v := states.steps[key] states.mu.Unlock() - if len(v) > 0 && v[len(v)-1].State != delivery.StepRunning { + if len(v) > 0 && v[len(v)-1].State != sdkstep.StepRunning { step = v[len(v)-1] break } @@ -72,7 +71,7 @@ func TestAPulledAskRunsTheBuildAndItsKeyNamesTheReference(t *testing.T) { t.Fatalf("the ask's step %s never ended: %v", key, v) } } - if step.State != delivery.StepPassed || !strings.Contains(string(step.Result), "artifact-store://mesh-build-go/tools@sha256:aa") { + if step.State != sdkstep.StepPassed || !strings.Contains(string(step.Result), "artifact-store://mesh-build-go/tools@sha256:aa") { t.Fatalf("the step = %+v", step) } if strings.Join(calls, ",") != "build_go_fetch,build_go_build" { @@ -98,8 +97,8 @@ func TestAtCapacityZeroNothingIsPulledAndTheToolsAnswer(t *testing.T) { defer stop() go p.Pull(ctx) time.Sleep(50 * time.Millisecond) - if src.Waiting(delivery.AskDelivery) != 1 || len(calls) != 0 { - t.Fatalf("a node at capacity 0 pulled: waiting %d, calls %v", src.Waiting(delivery.AskDelivery), calls) + if src.Waiting(sdkstep.AskDelivery) != 1 || len(calls) != 0 { + t.Fatalf("a node at capacity 0 pulled: waiting %d, calls %v", src.Waiting(sdkstep.AskDelivery), calls) } for _, tool := range m.Tools(steps) { if tool.Name == "build_go_fetch" { @@ -134,10 +133,10 @@ func TestManifestClaimsTheGoBuildSeatAndReadsItsCapacity(t *testing.T) { } _ = json.Unmarshal(raw, &doc) // Issue 1806's follow-up: the cores a step needs is a setting of its own, in the settings file, with the SDK's default. - if cores := string(doc.Config[buildstep.CoresPerStepSetting].Default); cores != fmt.Sprint(buildstep.DefaultCoresPerStep[delivery.AskGo]) { - t.Errorf("the cores-per-step default = %s, want %g", cores, buildstep.DefaultCoresPerStep[delivery.AskGo]) + if cores := string(doc.Config[buildstep.CoresPerStepSetting].Default); cores != fmt.Sprint(buildstep.DefaultCoresPerStep[sdkstep.AskGo]) { + t.Errorf("the cores-per-step default = %s, want %g", cores, buildstep.DefaultCoresPerStep[sdkstep.AskGo]) } - if got := buildstep.NodeCoresPerStep(moduleDir+"/files/settings.json", 0, nil)(); got != buildstep.DefaultCoresPerStep[delivery.AskGo] { + if got := buildstep.NodeCoresPerStep(moduleDir+"/files/settings.json", 0, nil)(); got != buildstep.DefaultCoresPerStep[sdkstep.AskGo] { t.Errorf("the settings template holds cores-per-step %g", got) } // And the build hours (the operator, 2026-10-26): a setting of their own, empty by default (any hour), in the template. @@ -198,13 +197,13 @@ func TestACheckAsksDigestIsTheToolsAnswerInTheCall(t *testing.T) { } } ask := m.askSteps(steps) - a := delivery.StepAsk{ID: sdkstep.NewID(), Kind: delivery.AskCheck, Language: delivery.AskGo, Asker: "mesh-delivery-checker", - At: time.Now(), Check: &delivery.CheckJob{Fetch: map[string]any{"repository": "novox/x", "commit": askCommit}, Levels: []string{delivery.LevelUnit}}} + a := sdkstep.StepAsk{ID: sdkstep.NewID(), Kind: sdkstep.AskCheck, Language: sdkstep.AskGo, Asker: "mesh-delivery-checker", + At: time.Now(), Check: &sdkstep.CheckJob{Fetch: sdkstep.Fetch{Repository: "novox/x", Commit: askCommit}, Levels: []string{sdkstep.LevelUnit}}} out, err := buildstep.RunAsk(context.Background(), ask, a) if err != nil { t.Fatal(err) } - if l := out.(delivery.CheckAnswer).Levels; len(l) != 1 || l[0].Digest.Map()["x/a"] != "d1" { + if l := out.(sdkstep.CheckAnswer).Levels; len(l) != 1 || l[0].Digest.Map()["x/a"] != "d1" { t.Fatalf("levels = %+v, want the digest's packages", l) } } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/beside_digest_test.go b/modules/mesh-build-go/cmd/mesh-build-go/beside_digest_test.go index 293cb0054..0bd255931 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/beside_digest_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/beside_digest_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // writeSiblings records the siblings the fetch placed, as the fetch does. @@ -95,7 +95,7 @@ func TestTheFetchRecordsTheSiblingsItPlaced(t *testing.T) { st := states.waitEnd(t) var res FetchResult json.Unmarshal(st.Result, &res) - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("the step ended %s: %s", st.State, st.Reason) } raw, err := os.ReadFile(filepath.Join(m.Work, res.Workspace, besideRecord)) diff --git a/modules/mesh-build-go/cmd/mesh-build-go/build.go b/modules/mesh-build-go/cmd/mesh-build-go/build.go index acc4e7dfc..cb4eab097 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/build.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/build.go @@ -14,8 +14,9 @@ import ( "strings" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" + artifactref "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient/reference" + builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" ) // The Go recipe of the build agent (toolchain.go, the entry of language go): static, trimmed of its own paths and of @@ -34,23 +35,27 @@ type BuildResult struct { Binary string `json:"binary"` Digest string `json:"digest"` Size int `json:"size"` - // Reference is where the archive is in the artifact store (novox/hq ADR 0498 §1): - // artifact-store:////blobs/. + // Reference is where the archive is, in the form the seat it was published through answers (novox/hq ADR 0605 + // §9): oci-artifact-registry//@. The builder writes the older form beside it while a + // counted node-engine reads only that (ADR 0624 §1, §3). Reference string `json:"reference"` } func (m *Module) buildTool() StepTool { return StepTool{ Name: "build_go_build", - Description: "Build one Go bundle or archive artifact of a module of a fetched workspace with the build agent's recipe, by the " + - "installed Go and no network, and pack it. Answers a step id at once; the step ends on its key with the " + - "artifact's digest, size and the archive's path.", + Description: "Build one Go bundle, archive or Go package artifact of a module of a fetched workspace with the build agent's " + + "recipe, by the installed Go and no network, and pack it (a Go package as Go's module zip); a bundle or archive is " + + "published on oci-artifact-registry, a Go package given a version on go-package-registry under its module path. " + + "Answers a step id at once; the step ends on its key with the artifact's digest, size and reference.", Input: map[string]any{ "type": "object", "properties": map[string]any{ "workspace": map[string]any{"type": "string", "description": "the workspace id build_go_fetch answered"}, "module": map[string]any{"type": "string", "description": "the module's directory in the workspace, . for the repository's root"}, "artifact": map[string]any{"type": "string", "description": "the artifact's name in the module's manifest"}, + "version": map[string]any{"type": "string", "description": "the version the builder gave a package at the trunk's claim; absent for a candidate, whose package is not published"}, + "seat": map[string]any{"type": "string", "description": "the registry seat the artifact is published through, as the build ask names it"}, "publish": map[string]any{"type": "boolean", "description": "true for a merged trunk commit alone: the build writes the shared Go caches; without it (a candidate) they are read beneath a throwaway layer"}, }, "required": []string{"workspace", "module", "artifact"}, @@ -109,6 +114,11 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) { if art == nil { return nil, fmt.Errorf("the module %s has no artifact %q (it has %s)", manifest.Module, name, strings.Join(names, ", ")) } + if art.Kind == catalogue.ArtifactPackage && art.Language == "go" { + version, _ := args["version"].(string) + seat, _ := args["seat"].(string) + return m.buildGoPackage(ctx, workspace, dir, *art, version, seat) + } if art.Kind == "archive" { return m.buildArchive(ctx, workspace, module, manifest.Module, dir, *art) } @@ -149,7 +159,7 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) { if err := m.insideModule(workspace, dir, out); err != nil { return nil, fmt.Errorf("the output directory of %s: %w", art.Name, err) } - res, err := m.packInto(ctx, workspace, module, manifest.Module, art.Name, out) + res, err := m.packInto(ctx, workspace, module, manifest.Module, art.Name, "bundle", out) if err != nil { return nil, err } @@ -157,20 +167,20 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) { return res, nil } -// packInto packs a directory with mesh-sdk's Pack, as the build agent packs every artifact, and pushes the archive to -// the artifact store from this process, after the build step ended (novox/hq ADR 0498 §1 §2): a candidate's as a trunk -// build's, under / by its digest. The step answers the reference; a push that fails fails the step -// `not stored:`. The packed bytes never stay on the node: they are held in this process until the push answered (the -// lead's decision L9). -func (m *Module) packInto(ctx context.Context, workspace, module, name, artifact, root string) (BuildResult, error) { +// packInto packs a directory with mesh-sdk's Pack, as the build agent packs every artifact, and publishes the archive +// on the seat oci-artifact-registry from this process, after the build step ended (design 51 *Publishing*; publish.go): +// a candidate's as a trunk build's, under / by its digest, of kind (bundle or archive). The step +// answers the reference; a publish the seat refuses fails the step `not stored:`. The packed bytes never stay on the +// node: they are held in this process until the publish answered (the lead's decision L9). +func (m *Module) packInto(ctx context.Context, workspace, module, name, artifact, kind, root string) (BuildResult, error) { archive, digest, err := buildstep.Pack(root) if err != nil { return BuildResult{}, fmt.Errorf("packing %s: %w", artifact, err) } - if err := m.push(ctx, name+"/"+artifact, digest, archive); err != nil { - return BuildResult{}, fmt.Errorf("%s%s: %v", delivery.NotStored, artifact, err) + if err := m.publishArchive(ctx, name, artifact, kind, digest, archive); err != nil { + return BuildResult{}, fmt.Errorf("%s%s: %v", builds.NotStored, artifact, err) } - return BuildResult{Artifact: artifact, Digest: digest, Size: len(archive), Reference: buildstep.BlobReference(name, artifact, digest)}, nil + return BuildResult{Artifact: artifact, Digest: digest, Size: len(archive), Reference: artifactref.SeatReference(ArtifactSeat, name, artifact, digest)}, nil } // buildEnv is the environment of the compile: the build agent's (CGO_ENABLED=0, GOFLAGS) with the installed go diff --git a/modules/mesh-build-go/cmd/mesh-build-go/build_test.go b/modules/mesh-build-go/cmd/mesh-build-go/build_test.go index 2583dfc48..16461569f 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/build_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/build_test.go @@ -14,7 +14,7 @@ import ( "strings" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -56,7 +56,7 @@ func buildModule(t *testing.T, manifest, content string) (*Module, *keptStates, return m, states, calls } -func runBuild(t *testing.T, m *Module, states *keptStates, artifact string) (BuildResult, delivery.Step) { +func runBuild(t *testing.T, m *Module, states *keptStates, artifact string) (BuildResult, sdkstep.Step) { t.Helper() for _, tool := range m.Tools(m.StepTools()) { if tool.Name == "build_go_build" { @@ -67,7 +67,7 @@ func runBuild(t *testing.T, m *Module, states *keptStates, artifact string) (Bui } st := states.waitEnd(t) var res BuildResult - if st.State == delivery.StepPassed { + if st.State == sdkstep.StepPassed { if err := json.Unmarshal(st.Result, &res); err != nil { t.Fatal(err) } @@ -106,7 +106,7 @@ func goEntry(t *testing.T) (compile []string, linker []string, stamp string) { func TestTheBuildCommandCarriesTodaysFlagsAndEnvironment(t *testing.T) { m, states, calls := buildModule(t, manifestWith(goBundle), "binary") res, st := runBuild(t, m, states, "tools") - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("the step ended %s: %s", st.State, st.Reason) } compile, linker, stamp := goEntry(t) @@ -177,12 +177,12 @@ func TestAnArtifactOfAnotherLanguageFailsTheStepNamingIt(t *testing.T) { } { m, states, calls := buildModule(t, manifestWith(artifact), "x") _, st := runBuild(t, m, states, "tools") - if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "tools") || !strings.Contains(st.Reason, want) || len(*calls) != 0 { + if st.State != sdkstep.StepFailed || !strings.Contains(st.Reason, "tools") || !strings.Contains(st.Reason, want) || len(*calls) != 0 { t.Errorf("%s: the step ended %s: %q after %d commands", artifact, st.State, st.Reason, len(*calls)) } } m, states, _ := buildModule(t, manifestWith(goBundle), "x") - if _, st := runBuild(t, m, states, "nope"); st.State != delivery.StepFailed || !strings.Contains(st.Reason, `"nope"`) || !strings.Contains(st.Reason, "tools") { + if _, st := runBuild(t, m, states, "nope"); st.State != sdkstep.StepFailed || !strings.Contains(st.Reason, `"nope"`) || !strings.Contains(st.Reason, "tools") { t.Errorf("an unknown artifact: %s %q", st.State, st.Reason) } } @@ -209,7 +209,7 @@ func TestAModuleDirectoryOutsideTheWorkspaceIsRefused(t *testing.T) { tool.Run(map[string]any{"workspace": "ws1", "module": "../../..", "artifact": "tools"}) } } - if st := states.waitEnd(t); st.State != delivery.StepFailed || len(*calls) != 0 { + if st := states.waitEnd(t); st.State != sdkstep.StepFailed || len(*calls) != 0 { t.Errorf("the step ended %s after %d commands", st.State, len(*calls)) } } @@ -230,7 +230,7 @@ func TestABuildOutputDirectoryLinkedOutsideTheModuleIsRefused(t *testing.T) { t.Fatal(err) } _, st := runBuild(t, m, states, "tools") - if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "outside") || !strings.Contains(st.Reason, outside) || len(*calls) != 0 { + if st.State != sdkstep.StepFailed || !strings.Contains(st.Reason, "outside") || !strings.Contains(st.Reason, outside) || len(*calls) != 0 { t.Errorf("the step ended %s: %q after %d commands", st.State, st.Reason, len(*calls)) } if body, err := os.ReadFile(kept); err != nil || string(body) != "mine" { @@ -248,7 +248,7 @@ func TestAManifestFallingBackToAPredefinedKeyBuilds(t *testing.T) { } m, states, _ := buildModule(t, string(raw), "binary") res, st := runBuild(t, m, states, "tools") - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("the step ended %s: %s", st.State, st.Reason) } if res.Artifact != "tools" || res.Binary != "claude-code" || !strings.HasPrefix(res.Digest, "sha256:") { diff --git a/modules/mesh-build-go/cmd/mesh-build-go/candidate.go b/modules/mesh-build-go/cmd/mesh-build-go/candidate.go index 27f43d778..71a6f287a 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/candidate.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/candidate.go @@ -14,7 +14,7 @@ import ( "strings" "sync" - "git.novox.be/novox/mesh-sdk/go/forgekey" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" ) // The runners of a digest or test step: this installed program, or the candidate's compiled from the workspace. @@ -111,7 +111,7 @@ func (m *Module) mergeCommit(ctx context.Context, workspace string) (string, err r := m.Run(ctx, nil, "git", "-C", filepath.Join(m.Work, workspace, "tree"), "rev-parse", "HEAD") commit := strings.TrimSpace(r.Stdout) if r.Status != 0 || r.Err != "" || commit == "" { - return "", fmt.Errorf("the workspace's commit: git rev-parse: %s", forgekey.Said(r.Err+"\n"+r.Stderr)) + return "", fmt.Errorf("the workspace's commit: git rev-parse: %s", dforgekey.Said(r.Err+"\n"+r.Stderr)) } return commit, nil } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/candidate_test.go b/modules/mesh-build-go/cmd/mesh-build-go/candidate_test.go index cf12dadd2..c18ea5452 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/candidate_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/candidate_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -88,14 +88,14 @@ func newCandidateRig(t *testing.T) *candidateRig { return cr } -func (cr *candidateRig) test(t *testing.T, args map[string]any) (TestResult, delivery.Step) { +func (cr *candidateRig) test(t *testing.T, args map[string]any) (TestResult, sdkstep.Step) { t.Helper() if _, err := stepTool(t, cr.m, "build_go_test")(args); err != nil { t.Fatal(err) } st := cr.states.waitEnd(t) var res TestResult - if st.State == delivery.StepPassed { + if st.State == sdkstep.StepPassed { if err := json.Unmarshal(st.Result, &res); err != nil { t.Fatalf("result %s: %v", st.Result, err) } @@ -119,7 +119,7 @@ func TestTheDigestAndTestToolsAnnounceTheRunner(t *testing.T) { // 1898 K1, ADR func TestTheUnitLevelUnderTheWorkspaceRunnerRunsTheCandidateShutOff(t *testing.T) { // 1898 K1, K2 (AC4's unit part) cr := newCandidateRig(t) res, st := cr.test(t, map[string]any{"workspace": "ws1", "level": LevelUnit, "runner": RunnerWorkspace, "packages": []any{"./..."}}) - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("the step ended %s: %s", st.State, st.Reason) } if res.Tests != 42 || res.Why != "mark" || res.JudgedBy != "candidate "+candidateSHA { @@ -146,7 +146,7 @@ func TestTheUnitLevelUnderTheWorkspaceRunnerRunsTheCandidateShutOff(t *testing.T func TestTheIntegrationLevelUnderTheWorkspaceRunnerRunsTheCandidate(t *testing.T) { // 1898 K2 cr := newCandidateRig(t) res, st := cr.test(t, map[string]any{"workspace": "ws1", "level": LevelIntegration, "runner": RunnerWorkspace}) - if st.State != delivery.StepPassed || res.JudgedBy != "candidate "+candidateSHA { + if st.State != sdkstep.StepPassed || res.JudgedBy != "candidate "+candidateSHA { t.Fatalf("the step ended %s (%s): %+v", st.State, st.Reason, res) } if len(cr.specs) != 1 || cr.specs[0].Step != stepIntegration || cr.specs[0].Bound != IntegrationRun || cr.specs[0].Setup != IntegrationSetup { @@ -177,7 +177,7 @@ func TestACandidateThatDoesNotCompileFailsTheStepWithTheCompilersFirstErrorAndRu cr := newCandidateRig(t) cr.compile = forgekey.Ran{Status: 1, Stderr: "# meshbuildgo/cmd/mesh-build-go\ncmd/mesh-build-go/digest.go:12:2: undefined: x\ncmd/mesh-build-go/digest.go:13:2: undefined: y\n"} _, st := cr.test(t, map[string]any{"workspace": "ws1", "level": LevelUnit, "runner": RunnerWorkspace}) - if st.State == delivery.StepPassed || !strings.Contains(st.Reason, "does not compile: cmd/mesh-build-go/digest.go:12:2: undefined: x") || strings.Contains(st.Reason, "undefined: y") { + if st.State == sdkstep.StepPassed || !strings.Contains(st.Reason, "does not compile: cmd/mesh-build-go/digest.go:12:2: undefined: x") || strings.Contains(st.Reason, "undefined: y") { t.Fatalf("the step ended %s: %q, want it failed naming the first compile error", st.State, st.Reason) } if len(cr.specs) != 0 { @@ -202,7 +202,7 @@ func TestTheInstalledProgramSaysItJudged(t *testing.T) { // 1898 K1, ADR 0535 § args["runner"] = runner } res, st := runUnitWith(t, m, states, args) - if st.State != delivery.StepPassed || res.JudgedBy != RunnerInstalled { + if st.State != sdkstep.StepPassed || res.JudgedBy != RunnerInstalled { t.Errorf("runner %v: the step ended %s, judged by %q, want installed", runner, st.State, res.JudgedBy) } } @@ -246,14 +246,14 @@ func TestTheShutOffProcessRunsTheStepItIsAsked(t *testing.T) { // 1898 K2: the c } } -func runUnitWith(t *testing.T, m *Module, states *keptStates, args map[string]any) (TestResult, delivery.Step) { +func runUnitWith(t *testing.T, m *Module, states *keptStates, args map[string]any) (TestResult, sdkstep.Step) { t.Helper() if _, err := stepTool(t, m, "build_go_test")(args); err != nil { t.Fatal(err) } st := states.waitEnd(t) var res TestResult - if st.State == delivery.StepPassed { + if st.State == sdkstep.StepPassed { if err := json.Unmarshal(st.Result, &res); err != nil { t.Fatalf("result %s: %v", st.Result, err) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/compose.go b/modules/mesh-build-go/cmd/mesh-build-go/compose.go index 08688be00..a44dd7a55 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/compose.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/compose.go @@ -7,9 +7,9 @@ package main // engine cannot judge its own next code. The module fetches the commit // (its fetch step: the forge key and the network, none of the change's code), builds the composer package in the Go // module the ask names, in a run step shut off with nothing fetched, and keeps the program under its key -// (delivery.ComposerKey), so each commit is built once on a node. It fills the request's manifests from the delivery +// (sdkstep.ComposerKey), so each commit is built once on a node. It fills the request's manifests from the delivery // indexer's manifests state, by digest, and runs the program shut off: no network, none of the account, the request a -// file of a workspace of its own. The answer is the composer's verdict whole (delivery.ComposeAnswer); the module +// file of a workspace of its own. The answer is the composer's verdict whole (sdkstep.ComposeAnswer); the module // judges nothing of it. import ( @@ -22,16 +22,24 @@ import ( "path/filepath" "strings" "sync" + "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" - "git.novox.be/novox/mesh-sdk/go/composer" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" + "git.novox.be/novox/mesh-delivery/sdk/composer" + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/views" ) -// ManifestsState is the delivery indexer's manifests by digest, which a compose ask's request names (the module reads -// it, module.json reads). -const ManifestsState = delivery.IndexerModule + ".manifests" +// ManifestsTool is the delivery indexer's read tool of its manifests by digest, which a compose ask's request names (the +// module invokes it, module.json invokes). A manifest by its digest never changes and a compose ask is rare, so the +// module asks for the one key it needs when it needs it, and keeps no picture of the indexer's whole state (ADR 0615). +// indexerModule is the module that holds the seat mesh-catalog, whose manifests state a compose ask reads. +const indexerModule = "mesh-delivery-indexer" + +const ManifestsTool = indexerModule + ".indexer_read_manifests" // composerLocks hold one mutex per composer key, so a composer two asks need at once is built once. var composerLocks sync.Map @@ -42,19 +50,14 @@ func (m *Module) composerPath(key string) string { } // compose runs a compose ask: the composer of its commit, built once, over its request with the manifests filled. -func (m *Module) compose(ctx context.Context, fetch buildstep.StepFunc, a delivery.StepAsk) (any, error) { +func (m *Module) compose(ctx context.Context, fetch buildstep.StepFunc, a sdkstep.StepAsk) (any, error) { job := *a.Compose - repository, _ := job.Fetch["repository"].(string) - commit, _ := job.Fetch["commit"].(string) + repository, commit := job.Fetch.Repository, job.Fetch.Commit installed, err := m.Probe(ctx) if err != nil { return nil, err } - versions, err := sdkstep.PackagesArg(job.Fetch["packages"]) - if err != nil { - return nil, buildstep.Refuse("the compose ask %s: %v", a.ID, err) - } - key := delivery.ComposerKey(repository, commit, job.Dir, job.Package, delivery.ToolchainUsed{Version: installed, Recipe: m.Recipe.Version()}, versions) + key := sdkstep.ComposerKey(repository, commit, job.Dir, job.Package, sdkstep.ToolchainUsed{Version: installed, Recipe: m.Recipe.Version()}, job.Fetch.Packages) exe, built, err := m.composerOf(ctx, fetch, job, key) if err != nil { return nil, err @@ -70,7 +73,35 @@ func (m *Module) compose(ctx context.Context, fetch buildstep.StepFunc, a delive if err != nil { return nil, err } - return delivery.ComposeAnswer{Built: built, Key: key, Verdict: verdict}, nil + return sdkstep.ComposeAnswer{Built: built, Key: key, Verdict: verdict}, nil +} + +// askManifest asks the indexer's read tool for one manifest by its key: nil when it keeps none. A call the indexer does not +// answer is a finding of this module's own, which the condition keeper raises (ADR 0615 §6) and the next answer clears. +func askManifest(key string) (json.RawMessage, error) { + return askManifestVia(stdio.Ask, meshconditionsclient.FindingWriter{State: stdio.State("findings")}, time.Now, key) +} + +func askManifestVia(ask func(string, any) (json.RawMessage, error), findings views.FindingWriter, now func() time.Time, key string) (json.RawMessage, error) { + st := views.Status{Owner: indexerModule, Tool: ManifestsTool, State: "manifests", Asked: now().UTC()} + raw, err := ask(ManifestsTool, map[string]any{"key": key}) + if err != nil { + st.Error = err.Error() + _ = findings.Put(views.UnansweredFinding("mesh-build-go", st, now())) + return nil, err + } + _ = findings.Delete(views.UnansweredFinding("mesh-build-go", st, now())) + var a struct { + Found bool `json:"found"` + Value json.RawMessage `json:"value"` + } + if err := json.Unmarshal(raw, &a); err != nil { + return nil, fmt.Errorf("%s answered something that is not a key's value: %w", ManifestsTool, err) + } + if !a.Found { + return nil, nil + } + return a.Value, nil } // manifestOf is the manifest the indexer keeps under digest, as its repository holds it. @@ -78,7 +109,7 @@ func (m *Module) manifestOf(digest string) ([]byte, error) { if m.ReadManifest == nil { return nil, errors.New("this module reads no manifests state") } - key, err := delivery.ManifestsKey(digest) + key, err := catalogclient.ManifestsKey(digest) if err != nil { return nil, err } @@ -89,16 +120,20 @@ func (m *Module) manifestOf(digest string) ([]byte, error) { if raw == nil { return nil, fmt.Errorf("the indexer keeps no manifest of digest %s", digest) } - man, err := delivery.DecodeManifest(raw) + man, err := catalogclient.DecodeManifest(raw) if err != nil { return nil, err } - return []byte(man.JSON), nil + return []byte(man.Manifest), nil } // composerOf is the composer kept under key, or, when none is, the one built now from the fetched commit (built true). -func (m *Module) composerOf(ctx context.Context, fetch buildstep.StepFunc, job delivery.ComposeJob, key string) (string, bool, error) { - return m.programOf(ctx, fetch, "composers", "composer", key, job.Fetch, job.Dir, job.Package) +func (m *Module) composerOf(ctx context.Context, fetch buildstep.StepFunc, job sdkstep.ComposeJob, key string) (string, bool, error) { + args, err := buildstep.FetchArgs(job.Fetch) + if err != nil { + return "", false, err + } + return m.programOf(ctx, fetch, "composers", "composer", key, args, job.Dir, job.Package) } // programOf is the program kept under key in the cache directory kind, or, when none is, the one built now: the commit diff --git a/modules/mesh-build-go/cmd/mesh-build-go/compose_integration_test.go b/modules/mesh-build-go/cmd/mesh-build-go/compose_integration_test.go index bf249d332..3310fdea5 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/compose_integration_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/compose_integration_test.go @@ -17,20 +17,23 @@ import ( "github.com/nats-io/nats.go/jetstream" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" - "git.novox.be/novox/mesh-sdk/go/composer" - "git.novox.be/novox/mesh-sdk/go/delivery" + catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" + "git.novox.be/novox/mesh-delivery/sdk/composer" "git.novox.be/novox/mesh-sdk/go/forgekey" "git.novox.be/novox/mesh-sdk/go/testbus" ) // nodeEngineTree is the node-engine's repository as the checker places it beside this check (MESH_CHECK_BESIDE, the -// repository mesh-host), and sdkBeside mesh-sdk's Go module there when it is placed too (a group that changes it). The test +// repository mesh-host), and with it every Go module of the other repositories placed there (mesh-sdk, mesh-contracts, +// mesh-delivery, mesh-networking, the catalogue's trunk) and of the catalogue under check, first, as candidates: the workspace lays those the +// node-engine's composer requires at the zero version, transitively (workspaceOf), so they resolve as the group lays +// them, wherever the composer lives. The test // builds the node-engine's real composer command from that tree; it does not stand one in. With no beside directory it // fails, and with the node-engine beside but no composer command in it it fails: a composer test that could not build its // composer proves nothing. With a beside directory that holds no node-engine at all it skips aloud (NOT JUDGED), since a // checker from before this release places none for the catalogue. -func nodeEngineTree(t *testing.T) (tree, sdk string) { +func nodeEngineTree(t *testing.T) (tree string, modules []string) { t.Helper() beside := os.Getenv("MESH_CHECK_BESIDE") if beside == "" { @@ -45,10 +48,41 @@ func nodeEngineTree(t *testing.T) (tree, sdk string) { if _, err := os.Stat(filepath.Join(tree, "cmd", "mesh-compose")); err != nil { t.Fatalf("NOT CHECKED: the node-engine's composer command is not beside this check in MESH_CHECK_BESIDE=%s: %v", beside, err) } - if dir := filepath.Join(beside, "mesh-sdk", "go"); dirExists(dir) { - sdk = dir + entries, err := os.ReadDir(beside) + if err != nil { + t.Fatal(err) } - return tree, sdk + var roots []string + for _, e := range entries { + if e.Name() == "mesh-host" || !dirExists(filepath.Join(beside, e.Name())) { + continue + } + root, err := filepath.EvalSymlinks(filepath.Join(beside, e.Name())) + if err != nil { + t.Fatal(err) + } + roots = append(roots, root) + } + // The catalogue under check, first: beside lies its trunk (issue 1295), whose modules a head's must win over. + catalogue, err := filepath.Abs(filepath.Join("..", "..", "..", "..")) + if err != nil { + t.Fatal(err) + } + for _, root := range append([]string{catalogue}, roots...) { + filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() && (d.Name() == ".git" || d.Name() == "testdata" || d.Name() == "node_modules") { + return filepath.SkipDir + } + if !d.IsDir() && d.Name() == "go.mod" { + modules = append(modules, filepath.Dir(p)) + } + return nil + }) + } + return tree, modules } func dirExists(p string) bool { st, err := os.Stat(p); return err == nil && st.IsDir() } @@ -111,7 +145,7 @@ func TestAComposeAskIsBuiltOnceAndAnsweredOnTheBus(t *testing.T) { } ctx, stop := context.WithCancel(context.Background()) defer stop() - if err := buildstep.DeclareAskWorkers(ctx, js, delivery.AskGo, 2*time.Minute); err != nil { + if err := buildstep.DeclareAskWorkers(ctx, js, sdkstep.AskGo, 2*time.Minute); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = js.DeleteStream(context.Background(), "SEAT_NODE_BUILD_GO") }) @@ -123,7 +157,7 @@ func TestAComposeAskIsBuiltOnceAndAnsweredOnTheBus(t *testing.T) { m.Run = StepRunner(t.TempDir()) m.GoModCache = goEnvOf(t, "GOMODCACHE") // the module cache this module's own build filled, read and not fetched run, gocache := m.Run, goEnvOf(t, "GOCACHE") // this machine's build cache, so the composer's packages compile once - hostTree, sdkDir := nodeEngineTree(t) + hostTree, beside := nodeEngineTree(t) workFile := "" m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { env = append(env, "GOCACHE="+gocache) @@ -147,50 +181,55 @@ func TestAComposeAskIsBuiltOnceAndAnsweredOnTheBus(t *testing.T) { return nil, err } copyHostTree(t, hostTree, tree) - if sdkDir != "" { // the group's mesh-sdk head, laid beside the node-engine's tree as one workspace - work := "go 1.26.0\n\nuse (\n\t" + tree + "\n\t" + sdkDir + "\n)\n" + if len(beside) > 0 { // the group's other repositories, laid beside the node-engine's tree as one workspace + work, err := workspaceOf(tree, beside) + if err != nil { + return nil, err + } if err := os.WriteFile(filepath.Join(m.Work, ws, "go.work"), []byte(work), 0o600); err != nil { return nil, err } workFile = filepath.Join(m.Work, ws, "go.work") + // Nothing is downloaded here: this level has no network. The check's own fetch downloaded this workspace's + // graph (downloadBeside), and a module it lacks fails the build naming it. } return map[string]any{"workspace": ws}, nil } } } - p := m.Puller(steps, &buildstep.JetStreamAsks{JS: js, Language: delivery.AskGo}) + p := m.Puller(steps, &buildstep.JetStreamAsks{JS: js, Language: sdkstep.AskGo}) p.PullWait = 200 * time.Millisecond go p.Pull(ctx) - ask := func() delivery.Step { + ask := func() sdkstep.Step { a := composeAskOf(t, digests[0]) - a.Compose.Fetch["repository"], a.Compose.Dir, a.Compose.Package = "novox/mesh-host", delivery.RootPath, "github.com/novox/mesh-host/cmd/mesh-compose" - raw, err := delivery.EncodeStepAsk(a) + a.Compose.Fetch.Repository, a.Compose.Dir, a.Compose.Package = "novox/mesh-host", catalogclient.RootPath, "github.com/novox/mesh-host/cmd/mesh-compose" + raw, err := sdkstep.EncodeStepAsk(a) if err != nil { t.Fatal(err) } - subject, _ := delivery.AskSubject(delivery.AskGo, delivery.AskCheck) + subject, _ := sdkstep.AskSubject(sdkstep.AskGo, sdkstep.AskCheck) if _, err := js.Publish(ctx, subject, raw); err != nil { t.Fatal(err) } - key, _ := delivery.AskStepKey("ace", a.ID) + key, _ := sdkstep.AskStepKey("ace", a.ID) for deadline := time.Now().Add(90 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { states.mu.Lock() v := states.steps[key] states.mu.Unlock() - if len(v) > 0 && v[len(v)-1].State != delivery.StepRunning { + if len(v) > 0 && v[len(v)-1].State != sdkstep.StepRunning { return v[len(v)-1] } } t.Fatalf("the compose ask %s never ended", a.ID) - return delivery.Step{} + return sdkstep.Step{} } for n, wantBuilt := range []bool{true, false} { step := ask() - if step.State != delivery.StepPassed { + if step.State != sdkstep.StepPassed { t.Fatalf("ask %d: %s: %s", n+1, step.State, step.Reason) } - var ans delivery.ComposeAnswer + var ans sdkstep.ComposeAnswer var v composer.Verdict if err := json.Unmarshal(step.Result, &ans); err != nil { t.Fatal(err) diff --git a/modules/mesh-build-go/cmd/mesh-build-go/compose_test.go b/modules/mesh-build-go/cmd/mesh-build-go/compose_test.go index 6520a50ad..b14272e25 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/compose_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/compose_test.go @@ -11,10 +11,12 @@ import ( "testing" "time" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" - "git.novox.be/novox/mesh-sdk/go/composer" - "git.novox.be/novox/mesh-sdk/go/delivery" + catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + meshcat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" + "git.novox.be/novox/mesh-delivery/sdk/composer" "git.novox.be/novox/mesh-sdk/go/forgekey" + "git.novox.be/novox/mesh-sdk/go/views" ) // nodeEngineComposer is the composer command of the node-engine's repository, whose Go module is at its root (ADR 0593 §7): @@ -28,9 +30,9 @@ func indexerHolds(t *testing.T, bodies ...string) (func(string) (json.RawMessage t.Helper() kept, digests := map[string]json.RawMessage{}, []string{} for _, b := range bodies { - d := delivery.ManifestDigest([]byte(b)) - key, _ := delivery.ManifestsKey(d) - raw, err := delivery.EncodeManifest(delivery.Manifest{Module: "demo", JSON: b}) + d := catalogclient.ManifestDigest([]byte(b)) + key, _ := catalogclient.ManifestsKey(d) + raw, err := catalogclient.EncodeManifest(meshcat.ManifestsByDigestValue{Module: "demo", Manifest: b}) if err != nil { t.Fatal(err) } @@ -39,12 +41,12 @@ func indexerHolds(t *testing.T, bodies ...string) (func(string) (json.RawMessage return func(key string) (json.RawMessage, error) { return kept[key], nil }, digests } -func composeAskOf(t *testing.T, digest string) delivery.StepAsk { +func composeAskOf(t *testing.T, digest string) sdkstep.StepAsk { t.Helper() req, _ := json.Marshal(composer.Request{V: composer.Version, Cases: []composer.Case{{Name: "change", Manifests: map[string]composer.Manifest{"demo": {Digest: digest}}}}}) - return delivery.StepAsk{ID: sdkstep.NewID(), Kind: delivery.AskCheck, Language: delivery.AskGo, Asker: "mesh-delivery-assigner", - At: time.Now(), Compose: &delivery.ComposeJob{Fetch: map[string]any{"repository": "novox/mesh-sdk", "commit": composeCommit}, + return sdkstep.StepAsk{ID: sdkstep.NewID(), Kind: sdkstep.AskCheck, Language: sdkstep.AskGo, Asker: "mesh-delivery-assigner", + At: time.Now(), Compose: &sdkstep.ComposeJob{Fetch: sdkstep.Fetch{Repository: "novox/mesh-sdk", Commit: composeCommit}, Dir: "go", Package: composer.Package, Request: req}} } @@ -65,7 +67,7 @@ func TestAKeptComposerIsRunWithTheManifestsFilled(t *testing.T) { read, digests := indexerHolds(t, `{"module":"demo","version":"1"}`) m.ReadManifest = read a := composeAskOf(t, digests[0]) - key := delivery.ComposerKey("novox/mesh-sdk", composeCommit, "go", composer.Package, delivery.ToolchainUsed{Version: "go1.26.0", Recipe: m.Recipe.Version()}, nil) + key := sdkstep.ComposerKey("novox/mesh-sdk", composeCommit, "go", composer.Package, sdkstep.ToolchainUsed{Version: "go1.26.0", Recipe: m.Recipe.Version()}, nil) exe := m.composerPath(key) if err := os.MkdirAll(filepath.Dir(exe), 0o700); err != nil { t.Fatal(err) @@ -83,7 +85,7 @@ func TestAKeptComposerIsRunWithTheManifestsFilled(t *testing.T) { if err != nil { t.Fatal(err) } - ans := out.(delivery.ComposeAnswer) + ans := out.(sdkstep.ComposeAnswer) var v composer.Verdict if err := json.Unmarshal(ans.Verdict, &v); err != nil { t.Fatal(err) @@ -102,10 +104,10 @@ func TestAComposerIsKeptPerAskedVersions(t *testing.T) { m := composeModule(t) m.ReadManifest, _ = indexerHolds(t) a := composeAskOf(t, "feedfeedfeedfeedfeedfeedfeedfeedfeedfeedfeedfeedfeedfeedfeedfeed") - tc := delivery.ToolchainUsed{Version: "go1.26.0", Recipe: m.Recipe.Version()} - plain := delivery.ComposerKey("novox/mesh-sdk", composeCommit, "go", composer.Package, tc, nil) - a.Compose.Fetch["packages"] = map[string]any{"git.novox.be/novox/mesh-sdk/go": strings.Repeat("3", 40)} - named := delivery.ComposerKey("novox/mesh-sdk", composeCommit, "go", composer.Package, tc, delivery.PackageVersions{"git.novox.be/novox/mesh-sdk/go": strings.Repeat("3", 40)}) + tc := sdkstep.ToolchainUsed{Version: "go1.26.0", Recipe: m.Recipe.Version()} + plain := sdkstep.ComposerKey("novox/mesh-sdk", composeCommit, "go", composer.Package, tc, nil) + a.Compose.Fetch.Packages = sdkstep.PackageVersions{"git.novox.be/novox/mesh-sdk/go": strings.Repeat("3", 40)} + named := sdkstep.ComposerKey("novox/mesh-sdk", composeCommit, "go", composer.Package, tc, a.Compose.Fetch.Packages) if plain == named { t.Fatal("the key ignores the versions") } @@ -129,7 +131,7 @@ func TestAComposeAskFailsLoudly(t *testing.T) { m := composeModule(t) m.ReadManifest, _ = indexerHolds(t) a := composeAskOf(t, "feedfeedfeedfeedfeedfeedfeedfeedfeedfeedfeedfeedfeedfeedfeedfeed") - key := delivery.ComposerKey("novox/mesh-sdk", composeCommit, "go", composer.Package, delivery.ToolchainUsed{Version: "go1.26.0", Recipe: m.Recipe.Version()}, nil) + key := sdkstep.ComposerKey("novox/mesh-sdk", composeCommit, "go", composer.Package, sdkstep.ToolchainUsed{Version: "go1.26.0", Recipe: m.Recipe.Version()}, nil) os.MkdirAll(filepath.Dir(m.composerPath(key)), 0o700) os.WriteFile(m.composerPath(key), []byte("#!/bin/sh\nexit 0\n"), 0o700) if _, err := m.compose(context.Background(), nil, a); err == nil || !strings.Contains(err.Error(), "feedfeed") { @@ -150,19 +152,40 @@ func TestAComposeAskFailsLoudly(t *testing.T) { } } -// 1622: the module reads the delivery indexer's manifests, which a compose ask names by digest. 1921 (B0): a run's input -// is asked of the checker seat's verb input, as the manifest invokes it, and no state of the checker's inputs is read -// (ADR 0596 §2). +// 1622, ADR 0615: the module asks the delivery indexer's read tool for the manifests a compose ask names by digest, and +// reads none of the indexer's state. 1921 (B0): a run's input is asked of the checker seat's verb input, as the manifest +// invokes it, and no state of the checker's inputs is read (ADR 0596 §2). func TestTheModuleReadsTheIndexersManifests(t *testing.T) { m := readManifest(t) - if !slices.Contains(m.Reads, ManifestsState) { - t.Errorf("reads = %v, lacks %s", m.Reads, ManifestsState) + if !slices.Contains(m.Invokes, ManifestsTool) || slices.Contains(m.Reads, indexerModule+".manifests") { + t.Errorf("invokes = %v, reads = %v: it asks %s", m.Invokes, m.Reads, ManifestsTool) } if !slices.Contains(m.Invokes, runInputVerb) || slices.Contains(m.Reads, "mesh-delivery-checker.inputs") { t.Errorf("invokes = %v, reads = %v: want %s invoked and no inputs state read", m.Invokes, m.Reads, runInputVerb) } } +// ADR 0615 §6: an indexer that does not answer is a finding of the module's own, and its next answer clears it. +func TestAnIndexerThatDoesNotAnswerIsAFinding(t *testing.T) { + held := map[string][]byte{} + f := mapFindings(held) + at := func() time.Time { return time.Date(2026, 11, 4, 12, 0, 0, 0, time.UTC) } + if _, err := askManifestVia(func(string, any) (json.RawMessage, error) { return nil, errors.New("no responders") }, f, at, "k"); err == nil || len(held) != 1 { + t.Fatalf("err %v, findings %v", err, held) + } + raw, err := askManifestVia(func(string, any) (json.RawMessage, error) { + return json.RawMessage(`{"found":true,"value":{"x":1}}`), nil + }, f, at, "k") + if err != nil || string(raw) != `{"x":1}` || len(held) != 0 { + t.Errorf("raw %s, err %v, findings %v", raw, err, held) + } +} + +type mapFindings map[string][]byte + +func (m mapFindings) Put(f views.Finding) error { m[f.ID+"."+f.Kind] = []byte(f.Summary); return nil } +func (m mapFindings) Delete(f views.Finding) error { delete(m, f.ID+"."+f.Kind); return nil } + // ADR 0593 §7 (C9): a compose ask naming the node-engine's repository builds its composer command in the Go module at the // repository's root, under a key of that repository, commit and package, and runs the program over the request with its // manifests filled; the module's answer is the program's verdict whole. @@ -171,7 +194,7 @@ func TestAComposeAskBuildsTheNodeEnginesComposerAtItsRoot(t *testing.T) { read, digests := indexerHolds(t, `{"module":"demo","version":"1"}`) m.ReadManifest = read a := composeAskOf(t, digests[0]) - a.Compose.Fetch["repository"], a.Compose.Dir, a.Compose.Package = "novox/mesh-host", delivery.RootPath, nodeEngineComposer + a.Compose.Fetch.Repository, a.Compose.Dir, a.Compose.Package = "novox/mesh-host", catalogclient.RootPath, nodeEngineComposer tree := "" var built []string real := m.Run @@ -201,8 +224,8 @@ func TestAComposeAskBuildsTheNodeEnginesComposerAtItsRoot(t *testing.T) { if err != nil { t.Fatal(err) } - ans := out.(delivery.ComposeAnswer) - key := delivery.ComposerKey("novox/mesh-host", composeCommit, delivery.RootPath, nodeEngineComposer, delivery.ToolchainUsed{Version: "go1.26.0", Recipe: m.Recipe.Version()}, nil) + ans := out.(sdkstep.ComposeAnswer) + key := sdkstep.ComposerKey("novox/mesh-host", composeCommit, catalogclient.RootPath, nodeEngineComposer, sdkstep.ToolchainUsed{Version: "go1.26.0", Recipe: m.Recipe.Version()}, nil) var v composer.Verdict if err := json.Unmarshal(ans.Verdict, &v); err != nil { t.Fatal(err) diff --git a/modules/mesh-build-go/cmd/mesh-build-go/digest.go b/modules/mesh-build-go/cmd/mesh-build-go/digest.go index 2a8310b3d..6a85a7ae6 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/digest.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/digest.go @@ -22,7 +22,8 @@ import ( "strconv" "strings" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" + trunk "git.novox.be/novox/mesh-delivery/sdk/trunk" ) // DigestVersion is the form of the digest's text: changed whenever what the digest hashes changes in kind. @@ -56,7 +57,7 @@ const namesVariable = "MESH_NODE_NAMES" const ReadsOutsideMarker = "reads-outside" // NeedsMarker is the name of a file in a package's directory that names, one per line, what its integration tests need -// of the node they run on (issue 890; mesh-sdk delivery.AskNeeds): `node-build-node` for tests that read the Node build +// of the node they run on (issue 890; mesh-delivery-sdk buildstep.AskNeeds): `node-build-node` for tests that read the Node build // module's installed toolchain. The digest reports it (DigestResult.Needs); a check ask leaves such a package untested on // a node that lacks it, and the checker asks it again of a node that has it. A marker beside the tests, as // ReadsOutsideMarker is, and not a manifest field: it is a property of the package's tests, read only where they are @@ -149,7 +150,7 @@ type DigestResult struct { neverKept map[string]string hashedFiles map[string]string // Needs are the packages whose tests at this level need something of the node, as their NeedsMarker names it. - Needs []delivery.PackageNeeds `json:"needs,omitempty"` + Needs []sdkstep.PackageNeeds `json:"needs,omitempty"` // JudgedBy is the program that ran the digest (ADR 0535 §5): "installed", or "candidate " under the // runner workspace. JudgedBy string `json:"judged_by,omitempty"` @@ -168,8 +169,8 @@ func (m *Module) digestTool() StepTool { "workspace": map[string]any{"type": "string", "description": "the workspace id build_go_fetch answered"}, "level": map[string]any{"type": "string", "enum": []string{LevelUnit, LevelIntegration}, "description": "the level"}, "runner": runnerProperty, - delivery.NodeNamesArg: map[string]any{"type": "array", "items": map[string]any{"type": "string"}, - "description": "optional, the names of the mesh's nodes: a package whose tests read " + delivery.NodeNamesEnv + " has them in its digest"}, + sdkstep.NodeNamesArg: map[string]any{"type": "array", "items": map[string]any{"type": "string"}, + "description": "optional, the names of the mesh's nodes: a package whose tests read " + sdkstep.NodeNamesEnv + " has them in its digest"}, }, "required": []string{"workspace", "level"}, }, @@ -184,7 +185,7 @@ func (m *Module) digest(ctx context.Context, args map[string]any) (any, error) { if err != nil { return nil, err } - names, err := nodeNamesArg(args[delivery.NodeNamesArg]) + names, err := nodeNamesArg(args[sdkstep.NodeNamesArg]) if err != nil { return nil, err } @@ -344,7 +345,7 @@ func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, he return fmt.Errorf("%s: its %s: %w", path, NeedsMarker, err) } if len(n) > 0 { - res.Needs = append(res.Needs, delivery.PackageNeeds{Package: path, Needs: n}) + res.Needs = append(res.Needs, sdkstep.PackageNeeds{Package: path, Needs: n}) } } declared, why, err := declaredReads(tree, root, res.hashed()) @@ -401,7 +402,7 @@ func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree s // A module outside the tree counts by its version and checksum; a package of the mesh's own forge by the content of // its files, as a local replacement does, so a new version of a mesh package whose reached files are the same keeps // every verdict kept for them (ADR 0593 §5, ADR 0444 §3). - if !mod.Main && mod.Version != "" && !strings.HasPrefix(mod.Path, delivery.ForgeModulePrefix) { + if !mod.Main && mod.Version != "" && !strings.HasPrefix(mod.Path, trunk.ForgeModulePrefix) { lines = append(lines, "module "+mod.Path+"@"+mod.Version+" "+mod.Sum) continue } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/digest_test.go b/modules/mesh-build-go/cmd/mesh-build-go/digest_test.go index 09d5a441b..689ff100e 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/digest_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/digest_test.go @@ -164,7 +164,11 @@ func TestAModuleOutsideTheTreeCountsByVersionAndChecksum(t *testing.T) { if err != nil { t.Fatal(err) } - ext := pkgs["git.novox.be/novox/mesh-sdk/go/buildstep"] + // The capture holds the one package go list was asked about, whichever package that was. + var ext *goPackage + for _, p := range pkgs { + ext = p + } if ext == nil || ext.Module == nil || ext.Module.Sum == "" || ext.Module.Version == "" || ext.Module.Main { t.Fatalf("the capture holds no external package with a version and a checksum: %+v", ext) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/fetch.go b/modules/mesh-build-go/cmd/mesh-build-go/fetch.go index e8707c2f5..59a06ade8 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/fetch.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/fetch.go @@ -8,6 +8,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" "os" "path/filepath" @@ -18,9 +19,8 @@ import ( "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/forgekey" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" ) var commitOK = regexp.MustCompile(`^[0-9a-f]{40}$`) @@ -202,7 +202,7 @@ func (m *Module) fetchInto(ctx context.Context, req sdkstep.FetchRequest, id str // that Go cannot fetch is looked for among the forge's pull request heads and handed to Go by a modfile (pins.go); a // failure that names no such pin is the first failure. A requirement at the zero version is taken from the asked // versions (pins.go takeZeroPins). -func (m *Module) downloadModules(ctx context.Context, root, repository, dir, d string, pins *sync.Mutex, asked delivery.PackageVersions) ([]TakenVersion, error) { +func (m *Module) downloadModules(ctx context.Context, root, repository, dir, d string, pins *sync.Mutex, asked sdkstep.PackageVersions) ([]TakenVersion, error) { var taken []TakenVersion for { failure := m.downloadAndVerify(ctx, dir, d) @@ -287,7 +287,7 @@ func (m *Module) downloadAndVerify(ctx context.Context, dir, d string) error { func (m *Module) goModDirs(ctx context.Context, tree string) ([]string, error) { r := m.Run(ctx, nil, "git", "-C", tree, "ls-files", "--", "go.mod", "*/go.mod") if r.Status != 0 || r.Err != "" { - return nil, fmt.Errorf("git ls-files: %s", forgekey.Said(r.Err+"\n"+r.Stderr)) + return nil, fmt.Errorf("git ls-files: %s", dforgekey.Said(r.Err+"\n"+r.Stderr)) } dirs := []string{} for _, l := range strings.Split(r.Stdout, "\n") { @@ -324,15 +324,58 @@ func (m *Module) goEnv() []string { if m.GoModCache != "" { modcache = m.GoModCache } - return append([]string{ + env := []string{ "GOTOOLCHAIN=local", "GOFLAGS=-mod=mod", "GOMODCACHE=" + modcache, "GOCACHE=" + filepath.Join(m.Cache, "gobuild"), - "GOPRIVATE=git.novox.be", - "GIT_TERMINAL_PROMPT=0", - "GIT_SSH_COMMAND=ssh -o BatchMode=yes", - }, m.threadEnv()...) + } + if proxy := m.goSeat(); proxy != "" { + // The Go seat first, the public proxy second, the forge's git last (CAT-BG3, E11: direct goes in the first + // change after mesh-sdk's and mesh-contracts' first numbered versions are in the Go seat); no GOPRIVATE, and + // the checksum database off for the forge's paths, which go.sum and the builder's h1: digest guard instead. + env = append(env, "GOPROXY="+proxy+","+PublicGoProxy+",direct", "GONOSUMDB="+ForgeGoPaths, "GOPRIVATE=") + } else { + env = append(env, "GOPRIVATE="+ForgeGoPaths) + } + return append(append(env, "GIT_TERMINAL_PROMPT=0", "GIT_SSH_COMMAND=ssh -o BatchMode=yes"), m.threadEnv()...) +} + +// PublicGoProxy is Go's public module proxy; ForgeGoPaths the module paths the forge serves. +const ( + PublicGoProxy = "https://proxy.golang.org" + ForgeGoPaths = "git.novox.be" +) + +// goSeat is the Go seat's proxy URL from its binding (`at`, and `serves` scheme, port and go-path), empty when the +// module holds no binding of it or the binding names no address. +func (m *Module) goSeat() string { + if m.GoSeatFile == "" { + return "" + } + raw, err := os.ReadFile(m.GoSeatFile) + if err != nil { + return "" + } + var b struct { + At string `json:"at"` + Serves struct { + Scheme string `json:"scheme"` + Port int `json:"port"` + GoPath string `json:"go-path"` + } `json:"serves"` + } + if json.Unmarshal(raw, &b) != nil || b.At == "" || b.Serves.GoPath == "" { + return "" + } + scheme := b.Serves.Scheme + if scheme == "" { + scheme = "http" + } + if b.Serves.Port == 0 { + return fmt.Sprintf("%s://%s%s", scheme, b.At, b.Serves.GoPath) + } + return fmt.Sprintf("%s://%s:%d%s", scheme, b.At, b.Serves.Port, b.Serves.GoPath) } // goEnvFor is goEnv for a command run in dir: a directory that vendors (vendor/modules.txt) is read from its vendor diff --git a/modules/mesh-build-go/cmd/mesh-build-go/fetch_test.go b/modules/mesh-build-go/cmd/mesh-build-go/fetch_test.go index 81d7c5d57..e01fd9b45 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/fetch_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/fetch_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -112,7 +112,7 @@ func TestFetchAsksGitForTheSshRemoteAndDownloadsEachModule(t *testing.T) { t.Fatal(err) } st := states.waitEnd(t) - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("the step ended %s: %s", st.State, st.Reason) } lines := strings.Join(f.lines(), "\n") @@ -145,6 +145,20 @@ func TestFetchAsksGitForTheSshRemoteAndDownloadsEachModule(t *testing.T) { } } } + // CAT-BG3: with the Go seat bound, the fetch goes through it first, Go's public proxy second and the forge's git + // last, with no GOPRIVATE and the checksum database off for the forge's paths. + seat := filepath.Join(t.TempDir(), "go-package-registry.json") + if err := os.WriteFile(seat, []byte(`{"at":"forge.mesh","serves":{"scheme":"http","port":3000,"go-path":"/api/packages/novox/go"}}`), 0o600); err != nil { + t.Fatal(err) + } + m.GoSeatFile = seat + env := strings.Join(m.goEnv(), " ") + for _, want := range []string{"GOPROXY=http://forge.mesh:3000/api/packages/novox/go,https://proxy.golang.org,direct", "GONOSUMDB=git.novox.be", "GOPRIVATE= "} { + if !strings.Contains(env+" ", want) { + t.Errorf("with the Go seat bound the environment lacks %q: %s", want, env) + } + } + m.GoSeatFile = "" if downloads != 2 || verifies != 2 { t.Errorf("%d downloads and %d verifies, want 2 each (go.mod and sub/dir/go.mod):\n%s", downloads, verifies, lines) } @@ -208,7 +222,7 @@ func TestAVerifyThatFailsEndsTheStepNamingTheDirectoryAndTheLine(t *testing.T) { t.Fatal(err) } st := states.waitEnd(t) - if st.State != delivery.StepFailed { + if st.State != sdkstep.StepFailed { t.Fatalf("the step ended %s, want failed", st.State) } if !strings.Contains(st.Reason, "tools") || !strings.Contains(st.Reason, "example.org/x v1.0.0: dir has been modified") || strings.Contains(st.Reason, "second line") { @@ -237,7 +251,7 @@ func TestTheCallAnswersAStepIdBeforeTheFetchEnds(t *testing.T) { states.mu.Lock() running := states.steps["ace."+id] states.mu.Unlock() - if len(running) != 1 || running[0].State != delivery.StepRunning { + if len(running) != 1 || running[0].State != sdkstep.StepRunning { t.Errorf("the key holds %+v, want one running step", running) } close(f.gate) @@ -253,7 +267,7 @@ func TestTheCallAnswersAStepIdBeforeTheFetchEnds(t *testing.T) { last := states.steps["ace."+id][len(states.steps["ace."+id])-1] states.mu.Unlock() var res FetchResult - if last.State != delivery.StepPassed || json.Unmarshal(last.Result, &res) != nil || res.Workspace == "" { + if last.State != sdkstep.StepPassed || json.Unmarshal(last.Result, &res) != nil || res.Workspace == "" { t.Errorf("the key ends %+v", last) } } @@ -264,7 +278,7 @@ func TestARepositoryThatIsNotOwnerSlashNameIsRefusedBeforeAnythingRuns(t *testin m, states := fetchModule(t, f) fetchTool(t, m)(map[string]any{"repository": repo, "commit": commitSHA}) st := states.waitEnd(t) - if st.State != delivery.StepFailed || len(f.lines()) != 0 { + if st.State != sdkstep.StepFailed || len(f.lines()) != 0 { t.Errorf("%q: the step ended %s after %d commands", repo, st.State, len(f.lines())) } } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/forge_test.go b/modules/mesh-build-go/cmd/mesh-build-go/forge_test.go index 89ff15c85..adeda47db 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/forge_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/forge_test.go @@ -10,9 +10,11 @@ import ( "encoding/json" "os" "path/filepath" + "slices" "strings" "testing" + "git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -143,10 +145,10 @@ func TestForgeFilesRenderFromTheBoundValuesInTheModulesOwnHome(t *testing.T) { // template in the files/ archive (novox/hq ADR 0349 §2). func TestManifestRequiresForgeAccountKeepsKeysAndTakesFilesFromTemplates(t *testing.T) { m := readForgeManifest(t) - // artifact-store: the module pushes a done build's archive itself (ADR 0498 §2). // node-tools: the module pulls its asks through the tool runner's seat pull (1911). - if len(m.Requires) != 4 || m.Requires[0] != "forge-account" || m.Requires[1] != "go-toolchain" || m.Requires[2] != "artifact-store" || m.Requires[3] != "node-tools" { - t.Errorf("requires = %v, want [forge-account go-toolchain artifact-store node-tools]", m.Requires) + // go-package-registry: the Go seat's read side, the fetch's first source (design 51, CAT-BG3, E2). + if want := []any{"forge-account", "go-toolchain", "go-package-registry", "node-tools"}; !slices.Equal(m.Requires, want) { + t.Errorf("requires = %v, want %v", m.Requires, want) } keeps := false for _, s := range m.State { @@ -236,7 +238,7 @@ func TestTheKeeperWritesTheModulesEntryWithKindBuilder(t *testing.T) { if !ok || len(st.puts) != 1 { t.Fatalf("entries = %v, want one at ace.%s", st.puts, forgeModule) } - var e forgekey.KeyEntry + var e sshkeys.KeyEntry if err := json.Unmarshal(raw, &e); err != nil { t.Fatal(err) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/gopackage.go b/modules/mesh-build-go/cmd/mesh-build-go/gopackage.go new file mode 100644 index 000000000..dbeaf4bee --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/gopackage.go @@ -0,0 +1,89 @@ +package main + +// The build of a Go package (CAT-BG2; novox/hq design 51 *Publishing*, ADR 0605 §3, §9): the directory the artifact +// names (its one path, else the module's own) packed as Go's module zip, every file under @/, +// as Go's module proxy protocol serves it; its digest is the zip's sha256 (no h1: sum is answered: nothing in the contract carries +// it on, the lead's ruling on A3n). A +// trunk build given a version by the builder publishes it through go-package-registry under its Go module path (the +// name the forge's Go seat takes, the lead's ruling 3 on A3k) and that version; a candidate's publishes nothing. The +// zip is made in this process, after nothing ran: packing a tree runs no code of it. + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "golang.org/x/mod/modfile" + "golang.org/x/mod/module" + modzip "golang.org/x/mod/zip" + + builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// GoPackageSeat is the registry seat a Go package is published on. +const GoPackageSeat = "go-package-registry" + +// candidateVersion is the version a candidate's zip is laid out under: no version was given, and nothing is published. +const candidateVersion = "v0.0.0" + +func (m *Module) buildGoPackage(ctx context.Context, workspace, dir string, art catalogue.Artifact, version, seat string) (BuildResult, error) { + root := dir + switch len(art.Paths) { + case 0: + case 1: + root = filepath.Join(dir, filepath.FromSlash(strings.TrimSuffix(art.Paths[0], "/"))) + default: + return BuildResult{}, fmt.Errorf("the Go package %s names %d paths; a Go package is one directory holding its go.mod", art.Name, len(art.Paths)) + } + if err := m.insideModule(workspace, dir, root); err != nil { + return BuildResult{}, fmt.Errorf("the directory of %s: %w", art.Name, err) + } + raw, err := os.ReadFile(filepath.Join(root, "go.mod")) + if err != nil { + return BuildResult{}, fmt.Errorf("the Go package %s has no go.mod in %s: %w", art.Name, art.Paths, err) + } + path := modfile.ModulePath(raw) + if path == "" { + return BuildResult{}, fmt.Errorf("the go.mod of the Go package %s names no module", art.Name) + } + if version != "" && seat != GoPackageSeat { + return BuildResult{}, fmt.Errorf("the Go package %s is given the version %s and the seat %q: a Go package is published through %s", art.Name, version, seat, GoPackageSeat) + } + zipVersion := candidateVersion + if version != "" { + zipVersion = "v" + strings.TrimPrefix(version, "v") + } + file, err := os.CreateTemp(filepath.Join(m.Work, workspace), "gopackage-*.zip") + if err != nil { + return BuildResult{}, err + } + defer os.Remove(file.Name()) // the packed file never stays on the node (the lead's decision L9) + defer file.Close() + if err := modzip.CreateFromDir(file, module.Version{Path: path, Version: zipVersion}, root); err != nil { + return BuildResult{}, fmt.Errorf("packing the Go package %s (%s@%s): %w", art.Name, path, zipVersion, err) + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + return BuildResult{}, err + } + body, err := io.ReadAll(file) + if err != nil { + return BuildResult{}, err + } + d := sha256.Sum256(body) + digest := "sha256:" + hex.EncodeToString(d[:]) + res := BuildResult{Artifact: art.Name, Digest: digest, Size: len(body)} + if version == "" { + return res, nil + } + if err := m.publishOn(ctx, seat, path, "package", version, digest, transferName(path, version, digest), body); err != nil { + return BuildResult{}, fmt.Errorf("%s%s: %v", builds.NotStored, art.Name, err) + } + res.Reference = path + "@" + version + return res, nil +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/imports.go b/modules/mesh-build-go/cmd/mesh-build-go/imports.go index a0995d101..e9ea32a9a 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/imports.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/imports.go @@ -14,8 +14,8 @@ import ( "slices" "strings" + trunk "git.novox.be/novox/mesh-delivery/sdk/trunk" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -94,14 +94,14 @@ func ModuleImports(ctx context.Context, run forgekey.Runner, env []string, dir s out = append(out, p) } slices.Sort(out) - if err := delivery.CheckImports(out); err != nil { + if err := trunk.CheckImports(out); err != nil { return ImportsResult{}, err } return ImportsResult{Imports: out}, nil } func meshPackage(pkg string) bool { - _, _, ok := delivery.PackageRepository(pkg) + _, _, ok := trunk.PackageRepository(pkg) return ok } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/installed_toolchain_test.go b/modules/mesh-build-go/cmd/mesh-build-go/installed_toolchain_test.go index 5e7ae030a..218e34a4d 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/installed_toolchain_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/installed_toolchain_test.go @@ -12,7 +12,7 @@ import ( "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/buildcpu" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/forgekey" "git.novox.be/novox/mesh-sdk/go/health" ) @@ -73,7 +73,7 @@ func TestTheFetchStepsGoCommandsSayGotoolchainLocalAndFetchNoToolchain(t *testin if _, err := fetchTool(t, m)(fetchArgs); err != nil { t.Fatal(err) } - if st := states.waitEnd(t); st.State != delivery.StepPassed { + if st := states.waitEnd(t); st.State != sdkstep.StepPassed { t.Fatalf("the step ended %s: %s", st.State, st.Reason) } goCalls := 0 diff --git a/modules/mesh-build-go/cmd/mesh-build-go/integration.go b/modules/mesh-build-go/cmd/mesh-build-go/integration.go index 8342aace9..f71b6a562 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/integration.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/integration.go @@ -14,7 +14,7 @@ import ( "strings" "time" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // The integration level's bounds (ADR 0445 §3). diff --git a/modules/mesh-build-go/cmd/mesh-build-go/integration_level_test.go b/modules/mesh-build-go/cmd/mesh-build-go/integration_level_test.go index fafb7bda0..e2cf5bad1 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/integration_level_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/integration_level_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -126,7 +126,7 @@ func integModule(t *testing.T, f *integFake) (*Module, *keptStates) { return m, states } -func runLevelTool(t *testing.T, m *Module, states *keptStates, level string) (TestResult, delivery.Step) { +func runLevelTool(t *testing.T, m *Module, states *keptStates, level string) (TestResult, sdkstep.Step) { t.Helper() for _, tool := range m.Tools(m.StepTools()) { if tool.Name != "build_go_test" { @@ -138,7 +138,7 @@ func runLevelTool(t *testing.T, m *Module, states *keptStates, level string) (Te } st := states.waitEnd(t) var res TestResult - if st.State == delivery.StepPassed { + if st.State == sdkstep.StepPassed { if err := json.Unmarshal(st.Result, &res); err != nil { t.Fatal(err) } @@ -152,7 +152,7 @@ func TestTheStoreListensOnASocketUnderTheRunAndNowhereElse(t *testing.T) { f := newIntegFake(t, "slow") m, states := integModule(t, f) res, st := runLevelTool(t, m, states, "integration") - if st.State != delivery.StepPassed || !res.Passed || res.Level != "integration" { + if st.State != sdkstep.StepPassed || !res.Passed || res.Level != "integration" { t.Fatalf("the step ended %s (%s): %+v", st.State, st.Reason, res) } var start, initdb string @@ -219,14 +219,14 @@ func TestAnotherPostgresMajorRefusesTheIntegrationLevelOnly(t *testing.T) { f.postgres = "postgres (PostgreSQL) 17.2" m, states := integModule(t, f) _, st := runLevelTool(t, m, states, "integration") - if st.State != delivery.StepRefused || !strings.Contains(st.Reason, "17") || !strings.Contains(st.Reason, "18") { + if st.State != sdkstep.StepRefused || !strings.Contains(st.Reason, "17") || !strings.Contains(st.Reason, "18") { t.Fatalf("the integration step ended %s: %q", st.State, st.Reason) } if strings.Contains(f.lines(), "initdb") || strings.Contains(f.lines(), "-tags") { t.Errorf("the refused level ran something:\n%s", f.lines()) } res, st := runLevelTool(t, m, states, "unit") - if st.State != delivery.StepPassed || !res.Passed || res.Level != "unit" { + if st.State != sdkstep.StepPassed || !res.Passed || res.Level != "unit" { t.Errorf("the unit level: %s %+v", st.State, res) } } @@ -308,7 +308,7 @@ func TestAStoreThatDoesNotStartEndsTheStepFailedAndLeavesNothing(t *testing.T) { return r } _, st := runLevelTool(t, m, states, "integration") - if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "pg_ctl: could not start server") { + if st.State != sdkstep.StepFailed || !strings.Contains(st.Reason, "pg_ctl: could not start server") { t.Errorf("the step ended %s: %q", st.State, st.Reason) } if !strings.Contains(f.lines(), " stop") { diff --git a/modules/mesh-build-go/cmd/mesh-build-go/isolate.go b/modules/mesh-build-go/cmd/mesh-build-go/isolate.go index a74fa4004..946fab4eb 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/isolate.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/isolate.go @@ -16,6 +16,7 @@ import ( "strings" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -64,7 +65,7 @@ func (m *Module) stepRunner(workspace string, write bool) (forgekey.Runner, erro func ProbeBubblewrap(ctx context.Context) (string, error) { ctx, cancel := context.WithTimeout(ctx, ProbeTimeout) defer cancel() - r := forgekey.ExecRunnerClean(0)(ctx, []string{"PATH=" + defaultPath}, buildstep.Bubblewrap, "--version") + r := dforgekey.ExecRunnerClean(0)(ctx, []string{"PATH=" + defaultPath}, buildstep.Bubblewrap, "--version") if r.Status != 0 || r.Err != "" { return "", fmt.Errorf("`%s --version` failed: %s", buildstep.Bubblewrap, firstLine(r.Err+"\n"+r.Stderr)) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/isolate_test.go b/modules/mesh-build-go/cmd/mesh-build-go/isolate_test.go index a872febb2..190224d77 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/isolate_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/isolate_test.go @@ -11,7 +11,7 @@ import ( "testing" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -118,7 +118,7 @@ func TestTheUnitLevelRunsEveryGoTestShutOff(t *testing.T) { m, states, ws, calls := workspaceFor(t, "failing") var seen []shutOff m.Run = throughBubblewrap(t, m.Run, &seen) - if _, st := runUnit(t, m, states, ws); st.State != delivery.StepPassed { + if _, st := runUnit(t, m, states, ws); st.State != sdkstep.StepPassed { t.Fatalf("the step ended %s: %s", st.State, st.Reason) } ran := 0 @@ -174,7 +174,7 @@ func TestABuildRunsShutOffAndOnlyATrunkBuildWritesTheCaches(t *testing.T) { if _, err := run(args); err != nil { t.Fatal(err) } - if st := states.waitEnd(t); st.State != delivery.StepPassed { + if st := states.waitEnd(t); st.State != sdkstep.StepPassed { t.Fatalf("publish %v: the step ended %s: %s", publish, st.State, st.Reason) } if len(seen) != 1 || !strings.HasPrefix(seen[0].command, "go ") || !strings.Contains(seen[0].command, " build ") { @@ -230,7 +230,7 @@ func TestTheIntegrationLevelRunsInOneShutOffProcess(t *testing.T) { t.Fatal(err) } st := states.waitEnd(t) - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("the step ended %s: %s", st.State, st.Reason) } var got TestResult diff --git a/modules/mesh-build-go/cmd/mesh-build-go/keys.go b/modules/mesh-build-go/cmd/mesh-build-go/keys.go index fb93a0de9..ba3baff9a 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/keys.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/keys.go @@ -13,14 +13,16 @@ import ( "strings" "time" + "git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/forgekey" ) // keeperFor is this module's keeper: go/forgekey's, of kind builder, for account in home on node, whose entry lies // at . of the keys state. -func keeperFor(home, node, account string, run forgekey.Runner, state forgekey.KeyPutter) *forgekey.KeyKeeper { - return &forgekey.KeyKeeper{Home: home, Node: node, Account: account, Kind: forgekey.KindBuilder, Run: run, State: state, Now: time.Now} +func keeperFor(home, node, account string, run forgekey.Runner, state sshkeys.KeyPutter) *sshkeys.KeyKeeper { + return &sshkeys.KeyKeeper{Home: home, Node: node, Account: account, Kind: dforgekey.KindBuilder, Run: run, State: state, Now: time.Now} } // startKeeper runs the keeper when the runtime launched this process for a node: outside a launch — a build, a diff --git a/modules/mesh-build-go/cmd/mesh-build-go/kit_test.go b/modules/mesh-build-go/cmd/mesh-build-go/kit_test.go index 054ff4f48..73fa84288 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/kit_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/kit_test.go @@ -6,7 +6,8 @@ import ( "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/conformance" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" + buildrun "git.novox.be/novox/mesh-delivery/sdk/buildrun" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // 1901/AC7: the module's own parse path (readGoTest, then the level's judging) keeps the failure contract on every captured @@ -27,7 +28,7 @@ func TestTheTestStepKeepsTheFailureContract(t *testing.T) { // case: a step a previous process left running is ended at once and said, whatever its age, and a step of this process // is kept. func TestTheStartSweepEndsWhatAPreviousProcessLeft(t *testing.T) { - conformance.LeftRunning(t, func(ctx context.Context, scan sdkstep.Scan, node string, before time.Time, logf func(string, ...any)) { + conformance.LeftRunning(t, func(ctx context.Context, scan buildrun.Scan, node string, before time.Time, logf func(string, ...any)) { (&Module{Node: node}).endLeftRunning(ctx, scan, before, logf) }) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/latepublish.go b/modules/mesh-build-go/cmd/mesh-build-go/latepublish.go new file mode 100644 index 000000000..66c6d7123 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/latepublish.go @@ -0,0 +1,27 @@ +package main + +// The late publish (CAT-BG2): a numbered bundle or archive's held digest published under its version, by the build +// modules' one late publish (buildstep/latepublish.go). + +import ( + "context" + + "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// NumberedPattern is the event type this module consumes for the late publish, as its manifest's `consumes` names it. +const NumberedPattern = buildstep.NumberedPattern + +// LatePublish publishes a numbered bundle or archive's held digest under its version on the artifact seat; a Go package +// publishes at its build, an image is mesh-build-image's. +func (m *Module) LatePublish(ctx context.Context, body []byte) (bool, error) { + call := m.Call + if call == nil { + call = stdio.Ask + } + return buildstep.LatePublish(ctx, call, body, ArtifactSeat, "bundle", "archive") +} + +// hearNumbered hears the builder's version.numbered for the late publish. +func (m *Module) hearNumbered(ctx context.Context) { buildstep.HearNumbered(ctx, m.LatePublish) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/level.go b/modules/mesh-build-go/cmd/mesh-build-go/level.go index 88281ee79..d61907753 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/level.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/level.go @@ -17,7 +17,7 @@ import ( "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // levelArg is the first argument that makes this binary a step's shut-off process: the integration level's, and, when @@ -148,7 +148,7 @@ func (m *Module) nodeToolchains() []string { const nodeToolchainMark = ".installed" // Has says whether this node has a need a check ask or a package's tests name (issue 890, ADR 0552; mesh-sdk -// delivery.AskNeeds), read from the node itself at every ask: node-build-node when the Node build module's toolchains +// sdkstep.AskNeeds), read from the node itself at every ask: node-build-node when the Node build module's toolchains // directory, given to the integration level as nodeToolchains gives it, holds a toolchain installed whole (its mark and // its package-lock.json readable by this account), and node, npm and bwrap are installed: what mesh-build-node's // integration tests (realMachine) look for before they run. Any other need it lacks. @@ -157,7 +157,7 @@ func (m *Module) Has(need string) bool { return m.HasNeed(need) } switch need { - case delivery.NeedNodeBuildNode: + case sdkstep.NeedNodeBuildNode: dirs := m.nodeToolchains() if len(dirs) == 0 { return false diff --git a/modules/mesh-build-go/cmd/mesh-build-go/levels.go b/modules/mesh-build-go/cmd/mesh-build-go/levels.go index beb540fec..bfc2a1d7a 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/levels.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/levels.go @@ -12,7 +12,7 @@ import ( "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // The two levels (ADR 0445 §2). @@ -72,6 +72,9 @@ type TestResult struct { // JudgedBy is the program that ran the level (ADR 0535 §5): "installed", or "candidate " under the // runner workspace. JudgedBy string `json:"judged_by,omitempty"` + // OutOfStep are the generated files of a contracts module in the workspace out of step with what the generator writes + // (CAT-BG5, buildstep.OutOfStep): the unit level fails on any, Why naming each. Absent when none is. + OutOfStep []string `json:"out-of-step,omitempty"` // By is the build module and the SDK it was built with (sdkstep.By): the checker names it when a failure hides // its cause. By string `json:"by,omitempty"` diff --git a/modules/mesh-build-go/cmd/mesh-build-go/lint.go b/modules/mesh-build-go/cmd/mesh-build-go/lint.go index 086a739d6..8a9f4fc1c 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/lint.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/lint.go @@ -15,7 +15,7 @@ import ( "sync" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/forgekey" ) diff --git a/modules/mesh-build-go/cmd/mesh-build-go/main.go b/modules/mesh-build-go/cmd/mesh-build-go/main.go index 906b8081c..1908fd8ea 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/main.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/main.go @@ -13,8 +13,9 @@ import ( "strings" "time" + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildgo" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/delivery" ) // The directories the module's manifest places outside its home (ADR 0466 §1): the workspaces, and the Go module @@ -37,13 +38,13 @@ func main() { m := &Module{ Node: strings.TrimSpace(os.Getenv("MESH_NODE")), Recipe: Current(), - Toolchains: stdio.State(delivery.ToolchainsState).Put, + Toolchains: stdio.State(seat.StateToolchains).Put, Emit: func(t string, b any) error { return stdio.Emit(t, b) }, - Steps: stdio.State(delivery.StepsState).Put, - ReadSteps: readOf(stdio.State(delivery.StepsState)), - Pauses: stdio.State(delivery.PausesState).Put, - ReadPauses: readOf(stdio.State(delivery.PausesState)), - ReadToolchainsState: readOf(stdio.State(delivery.ToolchainsState)), + Steps: stdio.State(seat.StateSteps).Put, + ReadSteps: readOf(stdio.State(seat.StateSteps)), + Pauses: stdio.State(sdkstep.PausesState).Put, + ReadPauses: readOf(stdio.State(sdkstep.PausesState)), + ReadToolchainsState: readOf(stdio.State(seat.StateToolchains)), Ctx: ctx, Say: logf, Work: WorkDir, @@ -54,10 +55,10 @@ func main() { m.Home = u.HomeDir } m.Run = StepRunner(m.Home) - m.RegistryFile = m.Home + "/registry" + m.GoSeatFile = m.Home + "/go-package-registry.json" m.Settings = m.Home + "/settings.json" m.ReadKept = readOf(stdio.State(KeptState)).Get - m.ReadManifest = readOf(stdio.State(ManifestsState)).Get + m.ReadManifest = askManifest m.AskInput = stdio.Ask m.Probe = ProbeGo(m.Home, m.Cache) m.Bubblewrap = ProbeBubblewrap @@ -83,6 +84,8 @@ func main() { if m.Node != "" { // The seat's asks, pulled once Serve has the runtime's channel; a pull before it is said and tried again. go func() { _ = puller.Pull(ctx) }() + // The builder's version.numbered, for the late publish of a held digest (latepublish.go). + go m.hearNumbered(ctx) } if err := stdio.Serve("", append(m.Tools(steps), m.SeatTools(puller)...)); err != nil { logf("%v", err) diff --git a/modules/mesh-build-go/cmd/mesh-build-go/manifest_test.go b/modules/mesh-build-go/cmd/mesh-build-go/manifest_test.go index 8e3790b95..80ae3f70f 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/manifest_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/manifest_test.go @@ -51,6 +51,14 @@ func resource(t *testing.T, m catalogue.Manifest, id string) map[string]any { // packages. func TestManifestDeclaresTheAccountTheCapabilityAndThePackages(t *testing.T) { m := readManifest(t) + // The key keeper is ssh-client's published package, taken by its series (ADR 0634 §1). + // The builder's step, answer and key forms are read through its published package, taken by its series (ADR 0634 §1). + if m.Packages["builds"] != "0.1" { + t.Errorf("packages %v: want builds of series 0.1", m.Packages) + } + if m.Packages["sshkeys"] != "0.1" { + t.Errorf("packages %v: want sshkeys of series 0.1", m.Packages) + } if m.Module != "mesh-build-go" || m.RunsAs != "mesh-build-go" { t.Errorf("module %q runs as %q, want mesh-build-go as itself", m.Module, m.RunsAs) } @@ -75,6 +83,17 @@ func TestManifestDeclaresTheAccountTheCapabilityAndThePackages(t *testing.T) { if want := []string{"bubblewrap", "git", "openssh", "postgresql"}; !slices.Equal(packages, want) { t.Errorf("packages = %v, want %v", packages, want) } + // buildstep, which mesh-build-node and mesh-build-image require, is a Go package of this module, numbered in + // series 0.1 (CAT-BG6; ADR 0606): its users accept a series, never a commit. + declared := false + for _, a := range m.Build.Artifacts { + if a.Name == "buildstep" { + declared = a.Kind == "package" && a.Language == "go" && slices.Equal(a.Paths, []string{"buildstep"}) && a.Series == "0.1" + } + } + if !declared { + t.Errorf("artifacts %+v declare no Go package buildstep in series 0.1", m.Build.Artifacts) + } } // The work and cache directories are outside the home (ADR 0466 §1), owned by the account. @@ -106,7 +125,7 @@ func TestManifestKeepsItsStatesClaimsItsBuildSeatAndDeclaresItsHealthTool(t *tes } } slices.Sort(states) - if want := []string{"keys", "pauses", "steps", "toolchains"}; !slices.Equal(states, want) { + if want := []string{"findings", "keys", "pauses", "steps", "toolchains"}; !slices.Equal(states, want) { t.Errorf("state = %v, want %v", states, want) } if len(m.Claims) != 1 || m.Claims[0].Name != Seat { @@ -159,21 +178,32 @@ func TestTheModuleDeclaresBubblewrapAndTheCapabilityItNeeds(t *testing.T) { } } -// 1587-S1 (ADR 0498 §2): the module requires the artifact store and is told its address in a file of its home, which no -// run step is given (a run step has no network either, ADR 0448). -func TestTheModuleRequiresTheArtifactStoreAndIsToldItsAddress(t *testing.T) { +// 1587-S1, CAT-BG1 (design 51 *Publishing*, ADR 0614 §2): the module publishes a done archive through the seat +// oci-artifact-registry's verbs, which it invokes (the served-verb edge orders it after the holder), and no longer +// requires the artifact store or is told its address: nothing writes into the store but its holder. +func TestTheModuleInvokesTheArtifactSeatsVerbsAndWritesNoStore(t *testing.T) { m := readManifest(t) - if !slices.Contains(m.Requires, "artifact-store") { - t.Errorf("requires %v lack artifact-store", m.Requires) + if slices.Contains(m.Requires, "artifact-store") { + t.Errorf("requires %v still name artifact-store", m.Requires) } - r := resource(t, m, "registry") - // The template (files/registry) is read below; decoding leaves it unread in the resource. - if r["type"] != "file" || r["path"] != "${dir:state}/registry" || r["template"] != "files/registry" { - t.Errorf("the registry resource is %v", r) + for _, seat := range []string{ArtifactSeat, GoPackageSeat} { // a Go package's too (CAT-BG2) + for _, verb := range []string{"publish", "publish-part"} { + if !slices.Contains(m.Invokes, "seat:"+seat+"."+verb) { + t.Errorf("invokes %v lack the seat %s's %s", m.Invokes, seat, verb) + } + } } - raw, err := os.ReadFile(filepath.Join(moduleDir, "files", "registry")) - if err != nil || strings.TrimSpace(string(raw)) != "${seat:mesh-artifact-store:reach}" { - t.Errorf("files/registry is %q (%v), want the store seat's reach", raw, err) + // The late publish hears the builder's version.numbered (latepublish.go). + if !slices.Contains(m.Consumes, NumberedPattern) { + t.Errorf("consumes %v lack %s", m.Consumes, NumberedPattern) + } + for _, r := range m.Resources { + if r["id"] == "registry" { + t.Errorf("the store's address is still placed: %v", r) + } + } + if _, err := os.Stat(filepath.Join(moduleDir, "files", "registry")); err == nil { + t.Error("files/registry is still shipped") } } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/mirror.go b/modules/mesh-build-go/cmd/mesh-build-go/mirror.go index 2f0420d61..7c3ab5633 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/mirror.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/mirror.go @@ -9,7 +9,7 @@ import ( "syscall" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - "git.novox.be/novox/mesh-sdk/go/forgekey" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" ) // mirrorLocks hold one mutex per mirror of this process; the lock file beside the mirror holds off another process. @@ -68,7 +68,7 @@ func (m *Module) dissociate(ctx context.Context, dir string) error { return nil // no mirror was used } if r := m.Run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, "git", "-C", dir, "repack", "-a", "-d", "-q"); r.Status != 0 || r.Err != "" { - return fmt.Errorf("git repack in %s: %s", dir, forgekey.Said(r.Err+"\n"+r.Stderr)) + return fmt.Errorf("git repack in %s: %s", dir, dforgekey.Said(r.Err+"\n"+r.Stderr)) } return os.Remove(alternates) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/mirror_test.go b/modules/mesh-build-go/cmd/mesh-build-go/mirror_test.go index a316780a6..6b5c39202 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/mirror_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/mirror_test.go @@ -3,7 +3,6 @@ package main import ( "encoding/json" "fmt" - "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" "os" "os/exec" "path/filepath" @@ -12,7 +11,8 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) func fetchOnce(t *testing.T, m *Module, states *keptStates, args map[string]any) FetchResult { @@ -21,7 +21,7 @@ func fetchOnce(t *testing.T, m *Module, states *keptStates, args map[string]any) t.Fatal(err) } st := states.waitEnd(t) - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("the step ended %s: %s", st.State, st.Reason) } var res FetchResult diff --git a/modules/mesh-build-go/cmd/mesh-build-go/module.go b/modules/mesh-build-go/cmd/mesh-build-go/module.go index d3ad804ea..57b041b24 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/module.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/module.go @@ -9,8 +9,8 @@ import ( "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" + buildrun "git.novox.be/novox/mesh-delivery/sdk/buildrun" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/forgekey" "git.novox.be/novox/mesh-sdk/go/health" ) @@ -41,6 +41,8 @@ type Module struct { // Ctx is the process's context, which a step runs under (the tool call's is cut at 30 s, the step's is not). Ctx context.Context Say func(format string, a ...any) + // Pause waits between a download and its retaking (versions.go); nil waits on a timer. A test sets it to record. + Pause func(d time.Duration) // Run runs the commands of a step (StepRunner); Work holds the workspaces and Cache the module and build caches, // both outside the account's home (ADR 0466 §1); Home is the account's home. Run forgekey.Runner @@ -67,10 +69,13 @@ type Module struct { // Inside is the integration level's own process, started shut off in bubblewrap by a test step (level.go): its // commands run as they are, never shut off a second time. Inside bool - // RegistryFile holds the artifact store's address (host:port), placed by the manifest; Push puts a blob there - // (buildstep.PushBlob when nil; a test's fake store). - RegistryFile string - Push func(ctx context.Context, registry, repository, digest string, body []byte) error + // GoSeatFile is the binding of the provision go-package-registry, the Go seat's read side (design 51 *Go + // packages*): bound, the mesh's Go modules are fetched through it first, Go's public proxy second, and the forge's + // git over the account's ssh last (Go's direct, kept for this milestone only); unbound, as before, directly. + GoSeatFile string + // Call asks a seat's verb over the bus: the publish of a done archive (publish.go). stdio.Ask when nil; a test's + // fake holder. + Call callFunc // Executable is this program's path, which the integration level starts again shut off; os.Executable when empty. Executable string // NodeToolchains is where the Node build module keeps its installed toolchains, bound read-only into the @@ -90,14 +95,14 @@ type Module struct { Settings string Capacity func() int ReadKept func(key string) (json.RawMessage, error) - // ReadManifest reads a key of the delivery indexer's manifests state (ManifestsState), which a compose ask's request - // names its manifests by (compose.go); GoModCache, when set, is the Go module cache in place of Cache's (a test). + // ReadManifest asks the delivery indexer's read tool (ManifestsTool) for one manifest by its key, which a compose ask's + // request names its manifests by (compose.go); it reads none of the indexer's state (ADR 0596); GoModCache, when set, is the Go module cache in place of Cache's (a test). ReadManifest func(key string) (json.RawMessage, error) // AskInput asks a seat verb the module invokes (module.json) as the runtime calls it (stdio.Ask): the verb a run ask's - // input names, answering the input by its digest (delivery.RunInput.Read). + // input names, answering the input by its digest (meshdeliverycheckerclient.ReadInput). AskInput func(key string, body any) (json.RawMessage, error) // PlaceHead, when set, lays a run's head at a directory instead of fetching it (a test). - PlaceHead func(ctx context.Context, h delivery.RunHead, dir string) error + PlaceHead func(ctx context.Context, h sdkstep.RunHead, dir string) error GoModCache string spacesOnce sync.Once @@ -131,7 +136,7 @@ func (m *Module) Start(ctx context.Context) { m.Say("the toolchain: %v", err) } m.CleanWorkspaces(time.Now()) - sdkstep.EndStaleRound(m.stepScan(), m.Node, m.Say) // asks past the step bound, on the module's own round (issue 1890) + buildrun.EndStaleRound(m.stepScan(), m.Node, m.Say) // asks past the step bound, on the module's own round (issue 1890) select { case <-ctx.Done(): return @@ -185,8 +190,8 @@ func (m *Module) Long(tool string, run func(ctx context.Context, args map[string if err != nil { return nil, err } - key, _ := delivery.StepKey(m.Node, id) - return map[string]any{"step": id, "key": key, "state": delivery.StepRunning}, nil + key, _ := sdkstep.StepKey(m.Node, id) + return map[string]any{"step": id, "key": key, "state": sdkstep.StepRunning}, nil } } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/module_test.go b/modules/mesh-build-go/cmd/mesh-build-go/module_test.go index eba3f02cc..99055d891 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/module_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/module_test.go @@ -9,24 +9,24 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/health" ) // keptStates stands in for the toolchains and the steps states. type keptStates struct { mu sync.Mutex - toolchains map[string]delivery.Toolchain - steps map[string][]delivery.Step + toolchains map[string]sdkstep.Toolchain + steps map[string][]sdkstep.Step ended chan string } func newStates() *keptStates { - return &keptStates{toolchains: map[string]delivery.Toolchain{}, steps: map[string][]delivery.Step{}, ended: make(chan string, 16)} + return &keptStates{toolchains: map[string]sdkstep.Toolchain{}, steps: map[string][]sdkstep.Step{}, ended: make(chan string, 16)} } func (s *keptStates) putToolchain(key string, value any) (uint64, error) { - tc, err := delivery.DecodeToolchain(value.(json.RawMessage)) + tc, err := sdkstep.DecodeToolchain(value.(json.RawMessage)) if err != nil { return 0, err } @@ -37,27 +37,27 @@ func (s *keptStates) putToolchain(key string, value any) (uint64, error) { } func (s *keptStates) putStep(key string, value any) (uint64, error) { - st, err := delivery.DecodeStep(value.(json.RawMessage)) + st, err := sdkstep.DecodeStep(value.(json.RawMessage)) if err != nil { return 0, err } s.mu.Lock() s.steps[key] = append(s.steps[key], st) s.mu.Unlock() - if st.State != delivery.StepRunning { + if st.State != sdkstep.StepRunning { s.ended <- key } return 1, nil } -func (s *keptStates) toolchain(key string) (delivery.Toolchain, bool) { +func (s *keptStates) toolchain(key string) (sdkstep.Toolchain, bool) { s.mu.Lock() defer s.mu.Unlock() tc, ok := s.toolchains[key] return tc, ok } -func (s *keptStates) waitEnd(t *testing.T) delivery.Step { +func (s *keptStates) waitEnd(t *testing.T) sdkstep.Step { t.Helper() select { case key := <-s.ended: @@ -67,7 +67,7 @@ func (s *keptStates) waitEnd(t *testing.T) delivery.Step { return v[len(v)-1] case <-time.After(30 * time.Second): t.Fatal("the step never ended on its key") - return delivery.Step{} + return sdkstep.Step{} } } @@ -85,7 +85,7 @@ func TestStartWritesTheToolchainAndHealthIsHealthy(t *testing.T) { ctx, stop := context.WithCancel(context.Background()) defer stop() go m.Start(ctx) - var tc delivery.Toolchain + var tc sdkstep.Toolchain for deadline := time.Now().Add(30 * time.Second); time.Now().Before(deadline); time.Sleep(5 * time.Millisecond) { if got, ok := states.toolchain("ace.go"); ok { tc = got @@ -127,7 +127,7 @@ func TestAGoThatCannotBeRunRefusesEveryStepAndHealthIsUnhealthy(t *testing.T) { if out.(map[string]any)["step"] == "" { t.Fatalf("%s answered no step id: %v", tool.Name, out) } - if st := states.waitEnd(t); st.State != delivery.StepRefused { + if st := states.waitEnd(t); st.State != sdkstep.StepRefused { t.Errorf("%s: the step ended %s, want refused", tool.Name, st.State) } else { reason = st.Reason diff --git a/modules/mesh-build-go/cmd/mesh-build-go/narrow_integration_test.go b/modules/mesh-build-go/cmd/mesh-build-go/narrow_integration_test.go index e74784787..d7e684fed 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/narrow_integration_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/narrow_integration_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // ADR 0499 §4 (issue 1758): a fetch asked with the module's directory and its named paths leaves the workspace holding @@ -28,7 +28,7 @@ func TestAFetchWithNamedPathsNarrowsTheWorkspace(t *testing.T) { }) m, states := f.module(t) res, st := fetched(t, m, states, map[string]any{"repository": "novox/narrow", "commit": head, "dir": "lib", "paths": []any{"cmd", "go.mod"}}) - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("the fetch ended %s: %s", st.State, st.Reason) } tree := filepath.Join(m.Work, res.Workspace, "tree") @@ -46,7 +46,7 @@ func TestAFetchWithNamedPathsNarrowsTheWorkspace(t *testing.T) { t.Errorf("the fetch answered the modules %v, want lib alone", res.Modules) } _, st = fetched(t, m, states, map[string]any{"repository": "novox/narrow", "commit": head, "paths": []any{"cmd"}}) - if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "no module directory") { + if st.State != sdkstep.StepFailed || !strings.Contains(st.Reason, "no module directory") { t.Errorf("paths without a directory: %s %q", st.State, st.Reason) } } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/needtools_test.go b/modules/mesh-build-go/cmd/mesh-build-go/needtools_test.go index afb362560..e39595e3c 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/needtools_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/needtools_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // Issue 1309: a test that runs a step in bubblewrap is proof only where it runs. Where a build module judges (it hands @@ -32,11 +32,11 @@ func TestAMissingToolSkipsExceptBubblewrapOnABuildNode(t *testing.T) { // The levels a build module judges hand the tests the build node mark, with the siblings they read. func TestEveryLevelHandsTheTestsTheBuildNodeMark(t *testing.T) { m := &Module{Work: t.TempDir()} - if env := m.checkEnv("ws1", nil); !slices.Contains(env, BuildNodeEnv+"=1") || slices.ContainsFunc(env, func(e string) bool { return strings.HasPrefix(e, delivery.NodeNamesEnv) }) { + if env := m.checkEnv("ws1", nil); !slices.Contains(env, BuildNodeEnv+"=1") || slices.ContainsFunc(env, func(e string) bool { return strings.HasPrefix(e, sdkstep.NodeNamesEnv) }) { t.Errorf("env %v lacks %s=1, or names nodes when none were asked", env, BuildNodeEnv) } // And the mesh's node names, when the ask carried them (ADR 0567 §12). - if env := m.checkEnv("ws1", []string{"a", "b"}); !slices.Contains(env, delivery.NodeNamesEnv+"=a,b") { + if env := m.checkEnv("ws1", []string{"a", "b"}); !slices.Contains(env, sdkstep.NodeNamesEnv+"=a,b") { t.Errorf("env %v lacks the node names", env) } } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/node_toolchains_test.go b/modules/mesh-build-go/cmd/mesh-build-go/node_toolchains_test.go index efd415cfa..a1f37d054 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/node_toolchains_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/node_toolchains_test.go @@ -9,7 +9,7 @@ import ( "strconv" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -36,7 +36,7 @@ func integrationShutOffWith(t *testing.T, toolchains string) shutOff { if _, err := run(map[string]any{"workspace": ws, "level": LevelIntegration}); err != nil { t.Fatal(err) } - if st := states.waitEnd(t); st.State != delivery.StepPassed { + if st := states.waitEnd(t); st.State != sdkstep.StepPassed { t.Fatalf("the step ended %s: %s", st.State, st.Reason) } if len(seen) != 1 { @@ -81,12 +81,12 @@ func TestANodeWithoutNodeToolchainsRunsTheLevelWithNothingMoreBound(t *testing.T } t.Setenv("PATH", bin) m := &Module{NodeToolchains: missing} - if m.Has(delivery.NeedNodeBuildNode) { + if m.Has(sdkstep.NeedNodeBuildNode) { t.Error("a node with no Node toolchain has node-build-node") } installed := t.TempDir() m.NodeToolchains = installed - if m.Has(delivery.NeedNodeBuildNode) { + if m.Has(sdkstep.NeedNodeBuildNode) { t.Error("a node with an empty toolchains directory has node-build-node") } half := filepath.Join(installed, "0.1.0") @@ -96,19 +96,19 @@ func TestANodeWithoutNodeToolchainsRunsTheLevelWithNothingMoreBound(t *testing.T if err := os.WriteFile(filepath.Join(half, "package-lock.json"), []byte("{}"), 0o644); err != nil { t.Fatal(err) } - if m.Has(delivery.NeedNodeBuildNode) { + if m.Has(sdkstep.NeedNodeBuildNode) { t.Error("a toolchain without its installed mark counts") } if err := os.WriteFile(filepath.Join(half, nodeToolchainMark), nil, 0o644); err != nil { t.Fatal(err) } - if !m.Has(delivery.NeedNodeBuildNode) || m.Has("gpu") { - t.Errorf("with a toolchain installed: node-build-node %v, gpu %v; want true, false", m.Has(delivery.NeedNodeBuildNode), m.Has("gpu")) + if !m.Has(sdkstep.NeedNodeBuildNode) || m.Has("gpu") { + t.Errorf("with a toolchain installed: node-build-node %v, gpu %v; want true, false", m.Has(sdkstep.NeedNodeBuildNode), m.Has("gpu")) } if err := os.Remove(filepath.Join(bin, "bwrap")); err != nil { t.Fatal(err) } - if m.Has(delivery.NeedNodeBuildNode) { + if m.Has(sdkstep.NeedNodeBuildNode) { t.Error("a node with no bubblewrap installed has node-build-node") } } @@ -120,7 +120,7 @@ func TestTheUnitLevelIsNotGivenTheNodeToolchains(t *testing.T) { m.NodeToolchains = dir var seen []shutOff m.Run = throughBubblewrap(t, m.Run, &seen) - if _, st := runUnit(t, m, states, ws); st.State != delivery.StepPassed { + if _, st := runUnit(t, m, states, ws); st.State != sdkstep.StepPassed { t.Fatalf("the step ended %s: %s", st.State, st.Reason) } for _, s := range seen { diff --git a/modules/mesh-build-go/cmd/mesh-build-go/packages_shape_test.go b/modules/mesh-build-go/cmd/mesh-build-go/packages_shape_test.go index fbb9ae92a..0490b6568 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/packages_shape_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/packages_shape_test.go @@ -8,8 +8,7 @@ import ( "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // 1925/AC1: packagesArg takes the list as the bus's JSON shape ([]any) and as the typed list an ask hands in process @@ -64,8 +63,8 @@ func TestACheckAskReachesTheTestStepPastItsPackages(t *testing.T) { } } } - a := delivery.StepAsk{ID: sdkstep.NewID(), Kind: delivery.AskCheck, Language: delivery.AskGo, Asker: "mesh-delivery-checker", - At: time.Now(), Check: &delivery.CheckJob{Fetch: map[string]any{"repository": "novox/x", "commit": askCommit}, Levels: []string{delivery.LevelUnit}}} + a := sdkstep.StepAsk{ID: sdkstep.NewID(), Kind: sdkstep.AskCheck, Language: sdkstep.AskGo, Asker: "mesh-delivery-checker", + At: time.Now(), Check: &sdkstep.CheckJob{Fetch: sdkstep.Fetch{Repository: "novox/x", Commit: askCommit}, Levels: []string{sdkstep.LevelUnit}}} out, err := buildstep.RunAsk(context.Background(), m.askSteps(steps), a) got := "" if err != nil { diff --git a/modules/mesh-build-go/cmd/mesh-build-go/pins.go b/modules/mesh-build-go/cmd/mesh-build-go/pins.go index aec9e034b..890d232b7 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/pins.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/pins.go @@ -21,8 +21,8 @@ import ( "strings" "sync" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/forgekey" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" ) const forgePrefix = "git.novox.be/novox/" @@ -151,7 +151,7 @@ func (m *Module) placePin(ctx context.Context, root string, p pin, failure strin r := m.Run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, "git", "-c", "credential.helper=", "ls-remote", m.remote(repository), "refs/pull/*/head") if r.Status != 0 || r.Err != "" { // The forge did not answer: retaken, with its own cause, never held as a refusal until the module changes (issue 1748). - return "", fmt.Errorf("the pin of %s at commit %s: listing the pull requests of %s failed (a download failure that may be retaken): %s", p.module, p.short, repository, forgekey.Said(r.Err+"\n"+r.Stderr)) + return "", fmt.Errorf("the pin of %s at commit %s: listing the pull requests of %s failed (a download failure that may be retaken): %s", p.module, p.short, repository, dforgekey.Said(r.Err+"\n"+r.Stderr)) } commit, ref := "", "" for _, l := range strings.Split(r.Stdout, "\n") { @@ -166,7 +166,7 @@ func (m *Module) placePin(ctx context.Context, root string, p pin, failure strin } return "", fmt.Errorf("the pin of %s at commit %s is on no pull request head of %s on the forge; go said: %s", p.module, p.short, repository, causeLine(failure)) } - // One placement per repository and commit, shared by every go.mod directory of the tree that pins it. + // One laid copy per repository and commit, shared by every go.mod directory of the tree that pins it. at := filepath.Join(root, "pinned", p.repo+"@"+commit) where := filepath.Join(at, "beside", p.repo, filepath.FromSlash(p.sub)) if _, err := os.Stat(filepath.Join(at, "beside", p.repo)); err == nil { @@ -258,7 +258,7 @@ func absoluteReplaces(body []byte, dir string) []byte { // the package's repository among the asked versions (the check's or try's members: another package of the same // repository names it); else the latest published version, the trunk's head. It never looks for the commit // 000000000000 among the pull request heads. It answers what it took; none when no zero requirement is named. -func (m *Module) takeZeroPins(ctx context.Context, root, repository, dir, d, failure string, asked delivery.PackageVersions, pins *sync.Mutex) ([]TakenVersion, error) { +func (m *Module) takeZeroPins(ctx context.Context, root, repository, dir, d, failure string, asked sdkstep.PackageVersions, pins *sync.Mutex) ([]TakenVersion, error) { raw, err := os.ReadFile(filepath.Join(dir, "go.mod")) if err != nil || !strings.Contains(failure, zeroShort) { return nil, nil @@ -321,7 +321,7 @@ func (m *Module) takeZeroPins(ctx context.Context, root, repository, dir, d, fai // askedFor is the commit the ask names for the package p: its own, else that of the first package of the same repository // in order (the members' heads are by repository), else none. -func askedFor(asked delivery.PackageVersions, p pin) string { +func askedFor(asked sdkstep.PackageVersions, p pin) string { if c := asked[p.module]; c != "" { return c } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/pins_integration_test.go b/modules/mesh-build-go/cmd/mesh-build-go/pins_integration_test.go index 9060b9506..d19157869 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/pins_integration_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/pins_integration_test.go @@ -13,7 +13,7 @@ import ( "path/filepath" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // 1148/AC2 as issue 1585 S15 has it (novox/hq ADR 0573 §4): given a forge with a mesh-sdk-like pull request whose branch @@ -46,7 +46,7 @@ func TestRealFetchAndBuildOfAMemberAskedAPullHeadItsGoModDoesNotName(t *testing. res, st := fetched(t, m, states, map[string]any{"repository": "novox/member", "commit": memberHead, "packages": map[string]any{"git.novox.be/novox/dep/go": head}}) - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("the fetch ended %s: %s", st.State, st.Reason) } if len(res.Packages) != 1 || res.Packages[0].Built != head || res.Packages[0].Named != "v0.0.0-20261021120000-"+fork[:12] { diff --git a/modules/mesh-build-go/cmd/mesh-build-go/pins_test.go b/modules/mesh-build-go/cmd/mesh-build-go/pins_test.go index 529294287..62bec6fe7 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/pins_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/pins_test.go @@ -10,7 +10,7 @@ import ( "sync" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -71,14 +71,14 @@ func newPinHarness(t *testing.T, gomod string, vendor bool, pulls string) (*pinH return h, states } -func (h *pinHarness) fetch(states *keptStates) (FetchResult, delivery.Step) { +func (h *pinHarness) fetch(states *keptStates) (FetchResult, sdkstep.Step) { h.t.Helper() if _, err := fetchTool(h.t, h.m)(fetchArgs); err != nil { h.t.Fatal(err) } st := states.waitEnd(h.t) var res FetchResult - if st.State == delivery.StepPassed { + if st.State == sdkstep.StepPassed { json.Unmarshal(st.Result, &res) } return res, st @@ -101,7 +101,7 @@ const pullList = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\trefs/pull/6/head\n" func TestAPinOnAPullHeadIsPlacedBesideAndHandedToGoByAModfile(t *testing.T) { h, states := newPinHarness(t, pinnedGoMod, false, pullList) res, st := h.fetch(states) - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("the step ended %s: %s", st.State, st.Reason) } placed := filepath.Join(h.m.Work, res.Workspace, "pinned", "mesh-sdk@"+commitSHA, "beside", "mesh-sdk") @@ -144,7 +144,7 @@ func TestAPinGoCanDownloadIsLeftAsToday(t *testing.T) { h, states := newPinHarness(t, pinnedGoMod, false, pullList) h.onTrunk = true res, st := h.fetch(states) - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("the step ended %s: %s", st.State, st.Reason) } if h.ran("ls-remote") || h.ran("pinned") { @@ -165,7 +165,7 @@ func TestAPinGoCanDownloadIsLeftAsToday(t *testing.T) { func TestAPinOnNoRefFailsNamingTheModuleAndTheCommit(t *testing.T) { h, states := newPinHarness(t, pinnedGoMod, false, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\trefs/pull/6/head\n") _, st := h.fetch(states) - if st.State != delivery.StepFailed { + if st.State != sdkstep.StepFailed { t.Fatalf("the step ended %s, want failed", st.State) } if !strings.Contains(st.Reason, "no pull request head") || !strings.Contains(st.Reason, "unknown revision") || @@ -181,7 +181,7 @@ func TestAPinOnNoRefFailsNamingTheModuleAndTheCommit(t *testing.T) { func TestAVendoringDirectoryResolvesNothing(t *testing.T) { h, states := newPinHarness(t, pinnedGoMod, true, pullList) _, st := h.fetch(states) - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("the step ended %s: %s", st.State, st.Reason) } if h.ran("go -C") || h.ran("ls-remote") || h.ran("pinned") { @@ -190,12 +190,12 @@ func TestAVendoringDirectoryResolvesNothing(t *testing.T) { } // 1148/AC1 (a repository with several go.mod directories, as mesh-catalog has): two directories pinning the same pull -// head share one placement and both are handed a modfile; the fetch does not fail placing it twice. -func TestTwoDirectoriesPinningOneHeadShareOnePlacement(t *testing.T) { +// head share one laid copy and both are handed a modfile; the fetch does not fail laying it twice. +func TestTwoDirectoriesPinningOneHeadShareOneCopy(t *testing.T) { h, states := newPinHarness(t, pinnedGoMod, false, pullList) h.f.gomods = "go.mod\nsub/go.mod\n" res, st := h.fetch(states) - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("the step ended %s: %s", st.State, st.Reason) } checkouts := 0 @@ -214,8 +214,8 @@ func TestTwoDirectoriesPinningOneHeadShareOnePlacement(t *testing.T) { } } -// 1148/AC1: two directories pinning two different commits of one repository get two placements, each its own. -func TestTwoCommitsOfOneRepositoryGetTwoPlacements(t *testing.T) { +// 1148/AC1: two directories pinning two different commits of one repository get two copies, each its own. +func TestTwoCommitsOfOneRepositoryGetTwoCopies(t *testing.T) { other := "fedcba9876543210fedcba9876543210fedcba98" h, states := newPinHarness(t, pinnedGoMod, false, commitSHA+"\trefs/pull/7/head\n"+other+"\trefs/pull/8/head\n") h.f.gomods = "go.mod\nsub/go.mod\n" @@ -240,12 +240,12 @@ func TestTwoCommitsOfOneRepositoryGetTwoPlacements(t *testing.T) { return base(ctx, env, name, args...) } _, st := h.fetch(states) - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("the step ended %s: %s", st.State, st.Reason) } for _, c := range []string{commitSHA, other} { if !h.ran("pinned/mesh-sdk@" + c) { - t.Errorf("no placement of mesh-sdk at %s", c) + t.Errorf("no copy of mesh-sdk at %s", c) } } } @@ -344,7 +344,7 @@ func TestARequirementAtTheZeroVersionIsTakenFromTheAskOrTheTrunk(t *testing.T) { t.Fatal(err) } st := states.waitEnd(t) - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("the step ended %s: %s", st.State, st.Reason) } if !h.ran(" mod download -json " + c.want) { @@ -384,7 +384,7 @@ func TestARequirementAtTheZeroVersionOfTheCheckedRepositorysOwnModuleIsTakenFrom t.Fatal(err) } st := states.waitEnd(t) - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("the step ended %s: %s", st.State, st.Reason) } modfiles, _ := filepath.Glob(filepath.Join(h.m.Work, "*", "pins", "*", "go.mod")) diff --git a/modules/mesh-build-go/cmd/mesh-build-go/preview_integration_test.go b/modules/mesh-build-go/cmd/mesh-build-go/preview_integration_test.go index b7acb409c..95cccd6c7 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/preview_integration_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/preview_integration_test.go @@ -15,7 +15,7 @@ import ( "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) func git(t *testing.T, dir string, args ...string) string { @@ -86,7 +86,7 @@ func (f *forge) module(t *testing.T) (*Module, *keptStates) { } // waitLong is waitEnd for a step that runs the real go, whose first build in a cold cache takes longer than 3 s. -func waitLong(t *testing.T, s *keptStates) delivery.Step { +func waitLong(t *testing.T, s *keptStates) sdkstep.Step { t.Helper() select { case key := <-s.ended: @@ -96,18 +96,18 @@ func waitLong(t *testing.T, s *keptStates) delivery.Step { return v[len(v)-1] case <-time.After(2 * time.Minute): t.Fatal("the step never ended on its key") - return delivery.Step{} + return sdkstep.Step{} } } -func fetched(t *testing.T, m *Module, states *keptStates, args map[string]any) (FetchResult, delivery.Step) { +func fetched(t *testing.T, m *Module, states *keptStates, args map[string]any) (FetchResult, sdkstep.Step) { t.Helper() if _, err := fetchTool(t, m)(args); err != nil { t.Fatal(err) } st := states.waitEnd(t) var res FetchResult - if st.State == delivery.StepPassed { + if st.State == sdkstep.StepPassed { if err := json.Unmarshal(st.Result, &res); err != nil { t.Fatal(err) } @@ -136,7 +136,7 @@ func TestRealFetchMergesConflictsAndPlainlyFetches(t *testing.T) { m, states := f.module(t) res, st := fetched(t, m, states, map[string]any{"repository": "o/r", "commit": clean, "ref": "refs/pull/1/head", "base": trunk}) - if st.State != delivery.StepPassed || res.Merge == "" || res.Base != trunk || res.Commit != clean || len(res.Conflicts) != 0 { + if st.State != sdkstep.StepPassed || res.Merge == "" || res.Base != trunk || res.Commit != clean || len(res.Conflicts) != 0 { t.Fatalf("clean: %s %s", st.State, st.Result) } if strings.Join(res.Changed, ",") != "modules/m/m.txt" { @@ -154,7 +154,7 @@ func TestRealFetchMergesConflictsAndPlainlyFetches(t *testing.T) { } res, st = fetched(t, m, states, map[string]any{"repository": "o/r", "commit": clash, "ref": "refs/pull/2/head", "base": trunk}) - if st.State != delivery.StepPassed || res.Merge != "" || strings.Join(res.Conflicts, ",") != "a.txt" { + if st.State != sdkstep.StepPassed || res.Merge != "" || strings.Join(res.Conflicts, ",") != "a.txt" { t.Fatalf("conflict: %s %s", st.State, st.Result) } if got := git(t, filepath.Join(m.Work, res.Workspace, "tree"), "rev-parse", "HEAD"); got != trunk { @@ -162,7 +162,7 @@ func TestRealFetchMergesConflictsAndPlainlyFetches(t *testing.T) { } res, st = fetched(t, m, states, map[string]any{"repository": "o/r", "commit": clean, "ref": "refs/pull/1/head"}) - if st.State != delivery.StepPassed || res.Merge != "" || res.Base != "" || res.Trees != nil || len(res.Modules) != 1 { + if st.State != sdkstep.StepPassed || res.Merge != "" || res.Base != "" || res.Trees != nil || len(res.Modules) != 1 { t.Errorf("no base: %s %s", st.State, st.Result) } } @@ -192,13 +192,13 @@ func TestRealTestsReadTheSiblingBesideTheTree(t *testing.T) { var res TestResult st := waitLong(t, states) json.Unmarshal(st.Result, &res) - if st.State != delivery.StepPassed || res.Passed || len(res.Failed) != 1 || !strings.Contains(res.Failed[0].Output, "MESH_CHECK_BESIDE is not set") { + if st.State != sdkstep.StepPassed || res.Passed || len(res.Failed) != 1 || !strings.Contains(res.Failed[0].Output, "MESH_CHECK_BESIDE is not set") { t.Fatalf("without the sibling: %s %s", st.State, st.Result) } with, st2 := fetched(t, m, states, map[string]any{"repository": "o/r", "commit": head, "beside": []any{map[string]any{"repository": "o/sibling", "commit": sibling}}}) - if st2.State != delivery.StepPassed || len(with.Beside) != 1 { + if st2.State != sdkstep.StepPassed || len(with.Beside) != 1 { t.Fatalf("with the sibling: %s %s", st2.State, st2.Result) } m.testToolRun(t, with.Workspace) @@ -223,7 +223,7 @@ func TestRealTestsReadTheSiblingBesideTheTree(t *testing.T) { git(t, work, "push", "-q", "-f", filepath.Join(f.root, "forge", "o/linked.git"), "HEAD:refs/heads/main") _, st3 := fetched(t, m, states, map[string]any{"repository": "o/r", "commit": head, "beside": []any{map[string]any{"repository": "o/linked", "commit": bad}}}) - if st3.State != delivery.StepFailed || !strings.Contains(st3.Reason, "etc-link") { + if st3.State != sdkstep.StepFailed || !strings.Contains(st3.Reason, "etc-link") { t.Errorf("a sibling with a symlink: %s %s", st3.State, st3.Reason) } } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/preview_test.go b/modules/mesh-build-go/cmd/mesh-build-go/preview_test.go index 6df51b810..87ff97f0c 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/preview_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/preview_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -69,7 +69,7 @@ func TestFetchWithABaseMergesAndAnswersThePreview(t *testing.T) { t.Fatal(err) } st := states.waitEnd(t) - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("the step ended %s: %s", st.State, st.Reason) } var res FetchResult @@ -102,7 +102,7 @@ func TestFetchWithAConflictEndsPassedAndNamesTheFiles(t *testing.T) { t.Fatal(err) } st := states.waitEnd(t) - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("a conflict ended the step %s: %s", st.State, st.Reason) } var res FetchResult @@ -137,7 +137,7 @@ func TestABaseThatIsNotACommitIsRefusedBeforeAnythingRuns(t *testing.T) { f := &fakeStep{} m, states := fetchModule(t, f) fetchTool(t, m)(map[string]any{"repository": "novox/mesh-sdk", "commit": commitSHA, "base": "main"}) - if st := states.waitEnd(t); st.State != delivery.StepFailed || len(f.lines()) != 0 { + if st := states.waitEnd(t); st.State != sdkstep.StepFailed || len(f.lines()) != 0 { t.Errorf("the step ended %s after %d commands", st.State, len(f.lines())) } } @@ -156,7 +156,7 @@ func TestSiblingsArePlacedBesideAndTheUnitLevelIsGivenTheirDirectory(t *testing. st := states.waitEnd(t) var res FetchResult json.Unmarshal(st.Result, &res) - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("the step ended %s: %s", st.State, st.Reason) } if len(res.Beside) != 2 || res.Beside[0].Dir != "mesh-catalog" || res.Beside[1].Dir != "mesh-host" { @@ -233,7 +233,7 @@ func TestSiblingsThatAreNotRepositoriesOrCommitsAreRefused(t *testing.T) { f := &fakeStep{} m, states := fetchModule(t, f) fetchTool(t, m)(map[string]any{"repository": "novox/mesh-sdk", "commit": commitSHA, "beside": beside}) - if st := states.waitEnd(t); st.State != delivery.StepFailed || len(f.lines()) != 0 { + if st := states.waitEnd(t); st.State != sdkstep.StepFailed || len(f.lines()) != 0 { t.Errorf("%s: the step ended %s after %d commands", name, st.State, len(f.lines())) } } @@ -285,7 +285,7 @@ func TestTheIntegrationLevelIsGivenTheBesideDirectoryOnlyWhenSiblingsWerePlaced( t.Fatal(err) } } - if _, st := runLevelTool(t, m, states, "integration"); st.State != delivery.StepPassed { + if _, st := runLevelTool(t, m, states, "integration"); st.State != sdkstep.StepPassed { t.Fatalf("the step ended %s: %s", st.State, st.Reason) } tested := 0 diff --git a/modules/mesh-build-go/cmd/mesh-build-go/probe.go b/modules/mesh-build-go/cmd/mesh-build-go/probe.go index f1a481269..13be6f6bd 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/probe.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/probe.go @@ -7,6 +7,7 @@ import ( "strings" "time" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -18,7 +19,7 @@ const ProbeTimeout = 15 * time.Second // module builds with, and the third word of its answer is that version. It downloads nothing and needs no network. // The environment is built from scratch like a step's (StepRunner), so the module's bus credentials are not in it. func ProbeGo(home, cache string) func(ctx context.Context) (string, error) { - run := forgekey.ExecRunnerClean(0) + run := dforgekey.ExecRunnerClean(0) return func(ctx context.Context) (string, error) { return probeGo(ctx, run, home, cache) } } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/publish.go b/modules/mesh-build-go/cmd/mesh-build-go/publish.go new file mode 100644 index 000000000..993a045f7 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/publish.go @@ -0,0 +1,42 @@ +package main + +// The publish of a done build's archive (novox/hq design 51 *Publishing*, ADR 0611; CAT-BG1): from this module's own +// process, after the build step ended, through the seat oci-artifact-registry's verbs publish and publish-part, which +// the manifest `invokes`, with the build modules' one sender (buildstep.PublishOne): the archive in chunks, only what the holder +// lacks. No build step reaches the store, and this module no longer writes into it directly (push.go is gone): the +// holder checks the build with the builder and says published or refused on the seat's events. + +import ( + "context" + "encoding/json" + + stdio "git.novox.be/novox/mesh-sdk/go" + + "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" +) + +// ArtifactSeat is the registry seat a bundle's or an archive's archive is published on. +const ArtifactSeat = buildstep.ArtifactSeat + +// transferName is the transfer's name, this module's own (buildstep.TransferName). +func transferName(module, artifact, digest string) string { + return buildstep.TransferName(module, artifact, digest) +} + +// publishArchive publishes body, of digest, as / of kind (bundle or archive) on the artifact seat. +func (m *Module) publishArchive(ctx context.Context, module, artifact, kind, digest string, body []byte) error { + return m.publishOn(ctx, ArtifactSeat, module+"/"+artifact, kind, "", digest, transferName(module, artifact, digest), body) +} + +// publishOn publishes body through buildstep.PublishOne: through Call when a test set it, else the runtime's request +// over the bus (stdio.Ask). +func (m *Module) publishOn(ctx context.Context, seat, name, kind, version, digest, transfer string, body []byte) error { + call := m.Call + if call == nil { + call = stdio.Ask + } + return buildstep.PublishOne(ctx, call, seat, name, kind, version, digest, transfer, body) +} + +// callFunc is the runtime's request and reply over the bus, as stdio.Ask makes it. +type callFunc = func(key string, body any) (json.RawMessage, error) diff --git a/modules/mesh-build-go/cmd/mesh-build-go/publish_test.go b/modules/mesh-build-go/cmd/mesh-build-go/publish_test.go new file mode 100644 index 000000000..4f5a587d9 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/publish_test.go @@ -0,0 +1,347 @@ +package main + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + registry "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" +) + +// pushed are the blobs the fake artifact store took, by digest, for the tests that read an archive back. +var pushed sync.Map + +// fakeStore is the seat oci-artifact-registry's holder as m's publish meets it (publish.go): publish opens a transfer +// and answers the part it lacks, publish-part takes the chunks in sequence, and the last one stores the whole part +// when it hashes to its digest. Each blob is kept by name and digest; fail, when set, is the holder's refusal at the +// last chunk; asked are the addresses m called. +type fakeStore struct { + mu sync.Mutex + blobs map[string][]byte // "@" + fail error + open map[string]registry.PublishInput + chunks map[string][]byte + asked []string + // published are the publishes whose last chunk was stored, in order. + published []registry.PublishInput +} + +func withStore(t *testing.T, m *Module) *fakeStore { + t.Helper() + s := &fakeStore{blobs: map[string][]byte{}, open: map[string]registry.PublishInput{}, chunks: map[string][]byte{}} + m.Call = func(key string, body any) (json.RawMessage, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.asked = append(s.asked, key) + raw, _ := json.Marshal(body) + seat, verb, _ := strings.Cut(strings.TrimPrefix(key, "seat:"), ".") + if seat != ArtifactSeat && seat != GoPackageSeat { + return nil, errors.New("nats: no responders available for request") + } + switch verb { + case "publish": + var in registry.PublishInput + _ = json.Unmarshal(raw, &in) + if (seat == ArtifactSeat) != (in.Kind == "bundle" || in.Kind == "archive") || in.Transfer == "" || strings.ContainsAny(in.Transfer, "/. *>") { + t.Errorf("publish of kind %q, transfer %q", in.Kind, in.Transfer) + } + s.open[in.Transfer] = in + // A digest held under the name already travels no more (the late publish, CAT-BG2). + if _, held := s.blobs[in.Name+"@"+in.Digest]; held { + s.published = append(s.published, in) + return json.Marshal(registry.PublishAnswer{Transfer: in.Transfer, Held: true}) + } + return json.Marshal(registry.PublishAnswer{Transfer: in.Transfer, Lacks: []string{in.Digest}}) + case "publish-part": + var c registry.PublishPartInput + _ = json.Unmarshal(raw, &c) + data, _ := base64.StdEncoding.DecodeString(c.Data) + s.chunks[c.Transfer] = append(s.chunks[c.Transfer], data...) + if !c.Last { + return json.Marshal(registry.PublishPartAnswer{Transfer: c.Transfer, Part: c.Part}) + } + in, whole := s.open[c.Transfer], s.chunks[c.Transfer] + delete(s.chunks, c.Transfer) + if s.fail != nil { + return nil, s.fail + } + sum := sha256.Sum256(whole) + if "sha256:"+hex.EncodeToString(sum[:]) != in.Digest { + t.Errorf("published %s with bytes of another digest", in.Digest) + } + s.blobs[in.Name+"@"+in.Digest] = whole + s.published = append(s.published, in) + pushed.Store(in.Digest, whole) + return json.Marshal(registry.PublishPartAnswer{Transfer: c.Transfer, Part: c.Part, Complete: true}) + } + return nil, errors.New("nats: no responders available for request") + } + return s +} + +// archiveOf is the archive a build pushed, by its digest. +func archiveOf(t *testing.T, res BuildResult) []byte { + t.Helper() + raw, ok := pushed.Load(res.Digest) + if !ok { + t.Fatalf("nothing was pushed under %s", res.Digest) + } + return raw.([]byte) +} + +// 1587-S2 (ADR 0498 §1): a done build answers a reference of the stated form whose tail is the build's digest, and the +// store holds the archive there; a bundle and an archive alike. +func TestADoneBuildAnswersTheReferenceOfTheArchiveItPushed(t *testing.T) { + m, states, _ := buildModule(t, manifestWith(goBundle), "the program") + store := withStore(t, m) + res, st := runBuild(t, m, states, "tools") + if st.State != sdkstep.StepPassed { + t.Fatalf("the step ended %s: %s", st.State, st.Reason) + } + if want := "oci-artifact-registry/demo/tools@" + res.Digest; res.Reference != want { + t.Errorf("reference %q, want %q", res.Reference, want) + } + if store.blobs["demo/tools@"+res.Digest] == nil || store.asked[0] != "seat:oci-artifact-registry.publish" { + t.Errorf("the seat was asked %v and holds %d blobs, not demo/tools@%s", store.asked, len(store.blobs), res.Digest) + } + a, states2, _ := archiveModule(t, "") + withStore(t, a) + arch, st := runBuild(t, a, states2, "files") + if st.State != sdkstep.StepPassed || arch.Reference != "oci-artifact-registry/demo/files@"+arch.Digest { + t.Errorf("the archive artifact: %s %q, reference %q", st.State, st.Reason, arch.Reference) + } +} + +// 1587-S3 (ADR 0498 §2), CAT-BG1: a publish the seat's holder refuses fails the step `not stored: : `, and so does a seat nothing holds. +func TestARefusedPushFailsTheStepNotStored(t *testing.T) { + m, states, _ := buildModule(t, manifestWith(goBundle), "the program") + store := withStore(t, m) + store.fail = errors.New("refused: the forge did not take it: disk full") + if _, st := runBuild(t, m, states, "tools"); st.State != sdkstep.StepFailed || !strings.HasPrefix(st.Reason, builds.NotStored+"tools: ") || !strings.Contains(st.Reason, "disk full") { + t.Errorf("the step ended %s: %q", st.State, st.Reason) + } + m.Call = func(string, any) (json.RawMessage, error) { + return nil, errors.New("nats: no responders available for request") + } + if _, st := runBuild(t, m, states, "tools"); st.State != sdkstep.StepFailed || !strings.HasPrefix(st.Reason, builds.NotStored+"tools: ") || + !strings.Contains(st.Reason, "oci-artifact-registry") { + t.Errorf("no holder: the step ended %s: %q", st.State, st.Reason) + } +} + +// 1587-S4 (the lead's decision L9): the packed file is not left on the node once its push answered, a candidate's +// included: the workspace holds no archive, pushed or refused. +func TestThePackedFileLeavesTheNodeOnceItsPushAnswered(t *testing.T) { + for _, fail := range []error{nil, errors.New("refused")} { + m, states, _ := buildModule(t, manifestWith(goBundle), "the program") + withStore(t, m).fail = fail + runBuild(t, m, states, "tools") + var archives []string + filepath.WalkDir(m.Work, func(p string, d os.DirEntry, err error) error { + if err == nil && strings.HasSuffix(p, ".tar.gz") { + archives = append(archives, p) + } + return nil + }) + if len(archives) != 0 { + t.Errorf("push answered %v: archives left on the node: %v", fail, archives) + } + } +} + +// CAT-BG2 (REG S23, ADR 0605 §3, §9; the lead's ruling 3 on A3k): a Go package is built as Go's module zip of the +// directory its artifact names, every file under @/, its digest the zip's. +// A trunk build given a version publishes it through go-package-registry under its Go module path and that version, +// and answers @; a candidate's (no version) publishes nothing and answers its digest alone. A +// directory with no go.mod fails the step naming it. +func TestAGoPackageIsBuiltAsAModuleZipAndPublishedUnderItsModulePath(t *testing.T) { + const pkg = `{"name":"go","kind":"package","language":"go","paths":["go"],"series":"0.1"}` + m, states, _ := buildModule(t, manifestWith(pkg), "unused") + src := filepath.Join(m.Work, "ws1", "tree", "modules", "demo", "go") + for name, body := range map[string]string{"go.mod": "module git.example/demo/go\n\ngo 1.26\n", "a.go": "package demo\n", + "vendor/x/x.go": "package x\n"} { + if err := os.MkdirAll(filepath.Dir(filepath.Join(src, name)), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + run := func(args map[string]any) (BuildResult, sdkstep.Step) { + t.Helper() + for _, tool := range m.Tools(m.StepTools()) { + if tool.Name == "build_go_build" { + if _, err := tool.Run(args); err != nil { + t.Fatal(err) + } + } + } + st := states.waitEnd(t) + var res BuildResult + if st.State == sdkstep.StepPassed { + _ = json.Unmarshal(st.Result, &res) + } + return res, st + } + store := withStore(t, m) + args := map[string]any{"workspace": "ws1", "module": "modules/demo", "artifact": "go"} + cand, st := run(args) + if st.State != sdkstep.StepPassed || !strings.HasPrefix(cand.Digest, "sha256:") || cand.Reference != "" || len(store.asked) != 0 { + t.Fatalf("a candidate's Go package: %s %q, %+v, asked %v", st.State, st.Reason, cand, store.asked) + } + args["version"], args["seat"] = "v0.1.15", GoPackageSeat + res, st := run(args) + if st.State != sdkstep.StepPassed || res.Reference != "git.example/demo/go@v0.1.15" { + t.Fatalf("a trunk Go package: %s %q, %+v", st.State, st.Reason, res) + } + if len(store.published) != 1 { + t.Fatalf("published %+v", store.published) + } + in := store.published[0] + if in.Name != "git.example/demo/go" || in.Version != "v0.1.15" || in.Kind != "package" || in.Digest != res.Digest { + t.Errorf("the publish: %+v", in) + } + z, err := zip.NewReader(bytes.NewReader(store.blobs[in.Name+"@"+in.Digest]), int64(len(store.blobs[in.Name+"@"+in.Digest]))) + if err != nil { + t.Fatal(err) + } + var names []string + for _, f := range z.File { + names = append(names, f.Name) + } + if strings.Join(names, " ") != "git.example/demo/go@v0.1.15/a.go git.example/demo/go@v0.1.15/go.mod" { + t.Errorf("the zip holds %v", names) + } + if err := os.Remove(filepath.Join(src, "go.mod")); err != nil { + t.Fatal(err) + } + if _, st := run(args); st.State != sdkstep.StepFailed || !strings.Contains(st.Reason, "go.mod") { + t.Errorf("no go.mod: %s %q", st.State, st.Reason) + } +} + +// K-2 (ADR 0634 §8): a module in a folder of the repository, the folder holding more than one Go package, builds and +// publishes each as a root module's would: each artifact its own zip of its own directory under its own module path, +// the sibling package's directory (a Go module of its own) left out of the zip of the folder's. +func TestEachGoPackageOfAModuleInAFolderIsPublishedUnderItsOwnModulePath(t *testing.T) { + manifest := manifestWith(`{"name":"go-aone","kind":"package","language":"go","paths":["go/seats/aone"],"series":"0.1"}, +{"name":"go-btwo","kind":"package","language":"go","paths":["go/seats/btwo"],"series":"0.1"}, +{"name":"go-whole","kind":"package","language":"go","paths":["go"],"series":"0.1"}`) + m, states, _ := buildModule(t, manifest, "unused") + src := filepath.Join(m.Work, "ws1", "tree", "domain-a", "contracts") + for name, body := range map[string]string{ + "module.json": manifest, + "go/seats/aone/go.mod": "module git.example/c/domain-a/go/seats/aone\n\ngo 1.26\n", "go/seats/aone/a.go": "package aone\n", + "go/seats/btwo/go.mod": "module git.example/c/domain-a/go/seats/btwo\n\ngo 1.26\n", "go/seats/btwo/b.go": "package btwo\n", + "go/go.mod": "module git.example/c/domain-a/go\n\ngo 1.26\n", "go/w.go": "package w\n"} { + if err := os.MkdirAll(filepath.Dir(filepath.Join(src, name)), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + store := withStore(t, m) + for _, c := range []struct{ artifact, ref string }{ + {"go-aone", "git.example/c/domain-a/go/seats/aone@v0.1.3"}, + {"go-btwo", "git.example/c/domain-a/go/seats/btwo@v0.1.3"}, + {"go-whole", "git.example/c/domain-a/go@v0.1.3"}, + } { + for _, tool := range m.Tools(m.StepTools()) { + if tool.Name == "build_go_build" { + args := map[string]any{"workspace": "ws1", "module": "domain-a/contracts", "artifact": c.artifact, "version": "v0.1.3", "seat": GoPackageSeat} + if _, err := tool.Run(args); err != nil { + t.Fatal(err) + } + } + } + st := states.waitEnd(t) + var res BuildResult + if st.State != sdkstep.StepPassed { + t.Fatalf("%s: %s %q", c.artifact, st.State, st.Reason) + } + _ = json.Unmarshal(st.Result, &res) + if res.Reference != c.ref { + t.Errorf("%s published as %q, want %q", c.artifact, res.Reference, c.ref) + } + } + if len(store.published) != 3 { + t.Fatalf("published %+v", store.published) + } + in := store.published[2] + z, err := zip.NewReader(bytes.NewReader(store.blobs[in.Name+"@"+in.Digest]), int64(len(store.blobs[in.Name+"@"+in.Digest]))) + if err != nil { + t.Fatal(err) + } + for _, f := range z.File { + if strings.Contains(f.Name, "/seats/") { + t.Errorf("the zip of the folder's own package holds %s, a package of its own", f.Name) + } + } +} + +// CAT-BG2 (design 51 *The versions*): the builder's version.numbered for a bundle or an archive publishes the held +// digest under its version, sending nothing; a digest the holder no longer holds fails the event (offered again), +// and what is not this module's to publish is let pass. +func TestTheNumberedEventPublishesAHeldDigestUnderItsVersion(t *testing.T) { + digest := "sha256:" + strings.Repeat("ab", 32) + other := "sha256:" + strings.Repeat("cd", 32) + numbered := func(kind, seat, digest string) []byte { + raw, err := builds.EncodeVersionNumbered(builds.VersionNumbered{Name: "mod/tools", Kind: kind, Version: "0.1.4", + Module: "mod", Fingerprint: "fp", Commit: "c0", Digest: digest, Seat: seat}) + if err != nil { + t.Fatal(err) + } + return raw + } + for _, c := range []struct { + name string + body []byte + did, fail bool + }{ + {"a held bundle is tagged", numbered("bundle", ArtifactSeat, digest), true, false}, + {"an archive with no seat named goes to the artifact seat", numbered("archive", "", digest), true, false}, + {"a digest the holder lacks fails, sending nothing", numbered("bundle", ArtifactSeat, other), false, true}, + {"a Go package is not published late", numbered("package", GoPackageSeat, digest), false, false}, + {"an image is mesh-build-image's", numbered("image", "container-image-registry", digest), false, false}, + {"no digest, nothing to publish", numbered("bundle", ArtifactSeat, ""), false, false}, + {"an unreadable body fails", []byte(`{"name":"mod/tools"}`), false, true}, + } { + t.Run(c.name, func(t *testing.T) { + m := &Module{} + s := withStore(t, m) + s.blobs["mod/tools@"+digest] = []byte("held") + did, err := m.LatePublish(t.Context(), c.body) + if did != c.did || (err != nil) != c.fail { + t.Fatalf("did %v, err %v; want did %v, fail %v", did, err, c.did, c.fail) + } + for _, k := range s.asked { + if strings.HasSuffix(k, ".publish-part") { + t.Errorf("a late publish sent a part: %v", s.asked) + } + } + if !c.did { + return + } + if len(s.published) != 1 { + t.Fatalf("published %v", s.published) + } + p := s.published[0] + if p.Name != "mod/tools" || p.Version != "0.1.4" || p.Digest != digest || p.Fingerprint != "fp" || p.Commit != "c0" { + t.Errorf("published %+v", p) + } + }) + } +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/push.go b/modules/mesh-build-go/cmd/mesh-build-go/push.go deleted file mode 100644 index fb468a033..000000000 --- a/modules/mesh-build-go/cmd/mesh-build-go/push.go +++ /dev/null @@ -1,42 +0,0 @@ -package main - -// The push of a done build's archive (novox/hq ADR 0498 §1 §2; issue 1587): from this module's own process, after the -// build step ended, with mesh-sdk's one client (buildstep.PushBlob), to the artifact store whose address the manifest -// binds into RegistryFile. No build step reaches the store: a run step has no network (ADR 0448). - -import ( - "context" - "fmt" - "net/http" - "os" - "strings" - "time" - - "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" -) - -// registry is the artifact store's address, host:port, as the manifest bound it into RegistryFile. -func (m *Module) registry() (string, error) { - raw, err := os.ReadFile(m.RegistryFile) - if err != nil { - return "", fmt.Errorf("the artifact store's address cannot be read: %w", err) - } - addr := strings.TrimSpace(string(raw)) - if addr == "" || strings.Contains(addr, "${") || strings.HasPrefix(addr, ":") || strings.HasSuffix(addr, ":") { - return "", fmt.Errorf("the artifact store's address in %s is %q, not host:port", m.RegistryFile, addr) - } - return addr, nil -} - -// push puts body into the artifact store as a blob of repository (/) by its digest: Push when a test -// set it, else buildstep.PushBlob. -func (m *Module) push(ctx context.Context, repository, digest string, body []byte) error { - addr, err := m.registry() - if err != nil { - return err - } - if m.Push != nil { - return m.Push(ctx, addr, repository, digest, body) - } - return buildstep.PushBlob(ctx, &http.Client{Timeout: 10 * time.Minute}, addr, repository, digest, body) -} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/push_test.go b/modules/mesh-build-go/cmd/mesh-build-go/push_test.go deleted file mode 100644 index b830db1b6..000000000 --- a/modules/mesh-build-go/cmd/mesh-build-go/push_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package main - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "os" - "path/filepath" - "strings" - "sync" - "testing" - - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// pushed are the blobs the fake artifact store took, by digest, for the tests that read an archive back. -var pushed sync.Map - -// fakeStore makes m push to a store of the test's own: its address in RegistryFile, each blob kept by repository and -// digest; fail, when set, is the store's refusal. -type fakeStore struct { - mu sync.Mutex - blobs map[string][]byte // "@" - fail error - addr string -} - -func withStore(t *testing.T, m *Module) *fakeStore { - t.Helper() - s := &fakeStore{blobs: map[string][]byte{}} - m.RegistryFile = filepath.Join(t.TempDir(), "registry") - if err := os.WriteFile(m.RegistryFile, []byte("store.test:5000\n"), 0o644); err != nil { - t.Fatal(err) - } - m.Push = func(ctx context.Context, registry, repository, digest string, body []byte) error { - s.mu.Lock() - defer s.mu.Unlock() - s.addr = registry - if s.fail != nil { - return s.fail - } - sum := sha256.Sum256(body) - if "sha256:"+hex.EncodeToString(sum[:]) != digest { - t.Errorf("pushed %s with bytes of another digest", digest) - } - s.blobs[repository+"@"+digest] = append([]byte(nil), body...) - pushed.Store(digest, s.blobs[repository+"@"+digest]) - return nil - } - return s -} - -// archiveOf is the archive a build pushed, by its digest. -func archiveOf(t *testing.T, res BuildResult) []byte { - t.Helper() - raw, ok := pushed.Load(res.Digest) - if !ok { - t.Fatalf("nothing was pushed under %s", res.Digest) - } - return raw.([]byte) -} - -// 1587-S2 (ADR 0498 §1): a done build answers a reference of the stated form whose tail is the build's digest, and the -// store holds the archive there; a bundle and an archive alike. -func TestADoneBuildAnswersTheReferenceOfTheArchiveItPushed(t *testing.T) { - m, states, _ := buildModule(t, manifestWith(goBundle), "the program") - store := withStore(t, m) - res, st := runBuild(t, m, states, "tools") - if st.State != delivery.StepPassed { - t.Fatalf("the step ended %s: %s", st.State, st.Reason) - } - if want := "artifact-store://demo/tools/blobs/" + res.Digest; res.Reference != want { - t.Errorf("reference %q, want %q", res.Reference, want) - } - if store.addr != "store.test:5000" || store.blobs["demo/tools@"+res.Digest] == nil { - t.Errorf("the store at %q holds %d blobs, not demo/tools@%s", store.addr, len(store.blobs), res.Digest) - } - a, states2, _ := archiveModule(t, "") - withStore(t, a) - arch, st := runBuild(t, a, states2, "files") - if st.State != delivery.StepPassed || arch.Reference != "artifact-store://demo/files/blobs/"+arch.Digest { - t.Errorf("the archive artifact: %s %q, reference %q", st.State, st.Reason, arch.Reference) - } -} - -// 1587-S3 (ADR 0498 §2): a push the store refuses fails the step `not stored: : `, and so -// does a store whose address the manifest did not place. -func TestARefusedPushFailsTheStepNotStored(t *testing.T) { - m, states, _ := buildModule(t, manifestWith(goBundle), "the program") - store := withStore(t, m) - store.fail = errors.New("store.test:5000 refused the blob: 500 Internal Server Error disk full") - if _, st := runBuild(t, m, states, "tools"); st.State != delivery.StepFailed || !strings.HasPrefix(st.Reason, delivery.NotStored+"tools: ") || !strings.Contains(st.Reason, "disk full") { - t.Errorf("the step ended %s: %q", st.State, st.Reason) - } - m.RegistryFile = filepath.Join(t.TempDir(), "absent") - if _, st := runBuild(t, m, states, "tools"); st.State != delivery.StepFailed || !strings.HasPrefix(st.Reason, delivery.NotStored+"tools: ") { - t.Errorf("no address: the step ended %s: %q", st.State, st.Reason) - } -} - -// 1587-S4 (the lead's decision L9): the packed file is not left on the node once its push answered, a candidate's -// included: the workspace holds no archive, pushed or refused. -func TestThePackedFileLeavesTheNodeOnceItsPushAnswered(t *testing.T) { - for _, fail := range []error{nil, errors.New("refused")} { - m, states, _ := buildModule(t, manifestWith(goBundle), "the program") - withStore(t, m).fail = fail - runBuild(t, m, states, "tools") - var archives []string - filepath.WalkDir(m.Work, func(p string, d os.DirEntry, err error) error { - if err == nil && strings.HasSuffix(p, ".tar.gz") { - archives = append(archives, p) - } - return nil - }) - if len(archives) != 0 { - t.Errorf("push answered %v: archives left on the node: %v", fail, archives) - } - } -} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/read.go b/modules/mesh-build-go/cmd/mesh-build-go/read.go index c0f9b979c..2682604f7 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/read.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/read.go @@ -14,8 +14,8 @@ import ( "sync" "time" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/delivery" ) // The read tools, named as the module is: build_go_ and the verb. @@ -98,26 +98,26 @@ func (m *Module) stepKey(id string) (string, error) { } id = rest } - return delivery.StepKey(m.Node, id) + return sdkstep.StepKey(m.Node, id) } // ReadStep is the stored value of this node's step id (state, reason, result). -func (m *Module) ReadStep(id string) (delivery.Step, error) { +func (m *Module) ReadStep(id string) (sdkstep.Step, error) { key, err := m.stepKey(id) if err != nil { - return delivery.Step{}, err + return sdkstep.Step{}, err } if m.ReadSteps.Get == nil { - return delivery.Step{}, fmt.Errorf("the steps state cannot be read here") + return sdkstep.Step{}, fmt.Errorf("the steps state cannot be read here") } raw, err := m.ReadSteps.Get(key) if err != nil { - return delivery.Step{}, err + return sdkstep.Step{}, err } if len(raw) == 0 { - return delivery.Step{}, fmt.Errorf("no step %s in the steps state", key) + return sdkstep.Step{}, fmt.Errorf("no step %s in the steps state", key) } - return delivery.DecodeStep(raw) + return sdkstep.DecodeStep(raw) } // StepLine is a step without its result: the list answers these. @@ -150,7 +150,7 @@ func (m *Module) RecentSteps(limit int) (lines []StepLine, cut bool, err error) own := keys[:0:0] for _, k := range keys { if strings.HasPrefix(k, m.Node+".") { - if node, _, err := delivery.ParseStepKey(k); err == nil && node == m.Node { + if node, _, err := sdkstep.ParseStepKey(k); err == nil && node == m.Node { own = append(own, k) } } @@ -164,12 +164,12 @@ func (m *Module) RecentSteps(limit int) (lines []StepLine, cut bool, err error) if err != nil { return nil, cut, err } - var steps []delivery.Step + var steps []sdkstep.Step for _, raw := range raws { if len(raw) == 0 { continue } - s, err := delivery.DecodeStep(raw) + s, err := sdkstep.DecodeStep(raw) if err != nil { continue // a value this module cannot read is not one of its own steps } @@ -191,7 +191,7 @@ func (m *Module) RecentSteps(limit int) (lines []StepLine, cut bool, err error) } // ReadToolchains is every key of the toolchains state (.) with its stored value. -func (m *Module) ReadToolchains() (map[string]delivery.Toolchain, error) { +func (m *Module) ReadToolchains() (map[string]sdkstep.Toolchain, error) { if m.ReadToolchainsState.Keys == nil || m.ReadToolchainsState.Get == nil { return nil, fmt.Errorf("the toolchains state cannot be read here") } @@ -209,12 +209,12 @@ func (m *Module) ReadToolchains() (map[string]delivery.Toolchain, error) { if err != nil { return nil, err } - out := map[string]delivery.Toolchain{} + out := map[string]sdkstep.Toolchain{} for i, raw := range raws { if len(raw) == 0 { continue } - t, err := delivery.DecodeToolchain(raw) + t, err := sdkstep.DecodeToolchain(raw) if err != nil { return nil, fmt.Errorf("%s: %w", keys[i], err) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/read_test.go b/modules/mesh-build-go/cmd/mesh-build-go/read_test.go index 05a85019b..0ff1eba1f 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/read_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/read_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // fakeState is a state as stored: key to raw value. @@ -29,13 +29,13 @@ func (f fakeState) read() StateRead { } } -func putStep(t *testing.T, f fakeState, s delivery.Step) { +func putStep(t *testing.T, f fakeState, s sdkstep.Step) { t.Helper() - b, err := delivery.EncodeStep(s) + b, err := sdkstep.EncodeStep(s) if err != nil { t.Fatal(err) } - key, _ := delivery.StepKey(s.Node, s.ID) + key, _ := sdkstep.StepKey(s.Node, s.ID) f[key] = b } @@ -60,22 +60,22 @@ func tool(t *testing.T, m *Module, name string) func(map[string]any) (any, error func TestStepToolAnswersTheStoredStep(t *testing.T) { steps := fakeState{} t0 := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC) - putStep(t, steps, delivery.Step{ID: "abc1", Tool: "build_go_fetch", Node: "ace", State: delivery.StepPassed, Started: t0, Ended: t0.Add(time.Second), + putStep(t, steps, sdkstep.Step{ID: "abc1", Tool: "build_go_fetch", Node: "ace", State: sdkstep.StepPassed, Started: t0, Ended: t0.Add(time.Second), Result: json.RawMessage(`{"workspace":"w42"}`)}) - putStep(t, steps, delivery.Step{ID: "abc2", Tool: "build_go_test", Node: "ace", State: delivery.StepRefused, Started: t0, Reason: "go cannot be run"}) + putStep(t, steps, sdkstep.Step{ID: "abc2", Tool: "build_go_test", Node: "ace", State: sdkstep.StepRefused, Started: t0, Reason: "go cannot be run"}) m := readModule(steps, fakeState{}) for _, id := range []string{"abc1", "ace.abc1"} { out, err := tool(t, m, StepReadTool)(map[string]any{"id": id}) if err != nil { t.Fatal(err) } - st := out.(delivery.Step) - if st.State != delivery.StepPassed || string(st.Result) != `{"workspace":"w42"}` || st.Tool != "build_go_fetch" { + st := out.(sdkstep.Step) + if st.State != sdkstep.StepPassed || string(st.Result) != `{"workspace":"w42"}` || st.Tool != "build_go_fetch" { t.Errorf("id %s: step = %+v", id, st) } } out, _ := tool(t, m, StepReadTool)(map[string]any{"id": "abc2"}) - if st := out.(delivery.Step); st.State != delivery.StepRefused || st.Reason != "go cannot be run" { + if st := out.(sdkstep.Step); st.State != sdkstep.StepRefused || st.Reason != "go cannot be run" { t.Errorf("refused step = %+v", st) } } @@ -94,10 +94,10 @@ func TestStepToolRefusesAnUnknownOrForeignOrBadID(t *testing.T) { func TestStepsToolListsRecentStepsOfThisNode(t *testing.T) { steps := fakeState{} t0 := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC) - putStep(t, steps, delivery.Step{ID: "a", Tool: "build_go_fetch", Node: "ace", State: delivery.StepPassed, Started: t0, Ended: t0, Result: json.RawMessage(`1`)}) - putStep(t, steps, delivery.Step{ID: "b", Tool: "build_go_test", Node: "ace", State: delivery.StepRunning, Started: t0.Add(time.Minute)}) - putStep(t, steps, delivery.Step{ID: "c", Tool: "build_go_build", Node: "ace", State: delivery.StepFailed, Started: t0.Add(2 * time.Minute), Reason: "boom", What: "check ask, novox/x@abc"}) - putStep(t, steps, delivery.Step{ID: "d", Tool: "build_go_build", Node: "g14", State: delivery.StepPassed, Started: t0.Add(time.Hour)}) + putStep(t, steps, sdkstep.Step{ID: "a", Tool: "build_go_fetch", Node: "ace", State: sdkstep.StepPassed, Started: t0, Ended: t0, Result: json.RawMessage(`1`)}) + putStep(t, steps, sdkstep.Step{ID: "b", Tool: "build_go_test", Node: "ace", State: sdkstep.StepRunning, Started: t0.Add(time.Minute)}) + putStep(t, steps, sdkstep.Step{ID: "c", Tool: "build_go_build", Node: "ace", State: sdkstep.StepFailed, Started: t0.Add(2 * time.Minute), Reason: "boom", What: "check ask, novox/x@abc"}) + putStep(t, steps, sdkstep.Step{ID: "d", Tool: "build_go_build", Node: "g14", State: sdkstep.StepPassed, Started: t0.Add(time.Hour)}) m := readModule(steps, fakeState{}) out, err := tool(t, m, StepsReadTool)(map[string]any{}) if err != nil { @@ -111,7 +111,7 @@ func TestStepsToolListsRecentStepsOfThisNode(t *testing.T) { if !slices.Equal(ids, []string{"c", "b", "a"}) { t.Errorf("ids = %v, want c b a", ids) } - if lines[0].Reason != "boom" || lines[0].What != "check ask, novox/x@abc" || lines[0].State != delivery.StepFailed { + if lines[0].Reason != "boom" || lines[0].What != "check ask, novox/x@abc" || lines[0].State != sdkstep.StepFailed { t.Errorf("first = %+v", lines[0]) } out, _ = tool(t, m, StepsReadTool)(map[string]any{"limit": float64(1)}) @@ -123,7 +123,7 @@ func TestStepsToolListsRecentStepsOfThisNode(t *testing.T) { // 1562 (1483/AC5): build_go_toolchains answers each toolchains key as stored. func TestToolchainsToolAnswersTheStoredKeys(t *testing.T) { tcs := fakeState{} - b, err := delivery.EncodeToolchain(delivery.Toolchain{Language: "go", Version: "go1.27.1", Recipe: "1", At: time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)}) + b, err := sdkstep.EncodeToolchain(sdkstep.Toolchain{Language: "go", Version: "go1.27.1", Recipe: "1", At: time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)}) if err != nil { t.Fatal(err) } @@ -133,7 +133,7 @@ func TestToolchainsToolAnswersTheStoredKeys(t *testing.T) { if err != nil { t.Fatal(err) } - got := out.(map[string]any)["toolchains"].(map[string]delivery.Toolchain) + got := out.(map[string]any)["toolchains"].(map[string]sdkstep.Toolchain) if got["ace.go"].Version != "go1.27.1" || got["ace.go"].Recipe != "1" { t.Errorf("toolchains = %+v", got) } @@ -166,7 +166,7 @@ func TestRecentStepsReadsOnlyTheNodesKeysInParallelAndBounded(t *testing.T) { stored := map[string][]byte{} for _, n := range []string{"ace", "g14", "novox", "shanks"} { for i := 0; i < row.perNode; i++ { - b, _ := delivery.EncodeStep(delivery.Step{ID: fmt.Sprintf("s%04d", i), Tool: "t", Node: n, State: delivery.StepPassed, Started: t0.Add(time.Duration(i) * time.Second)}) + b, _ := sdkstep.EncodeStep(sdkstep.Step{ID: fmt.Sprintf("s%04d", i), Tool: "t", Node: n, State: sdkstep.StepPassed, Started: t0.Add(time.Duration(i) * time.Second)}) stored[n+".s"+fmt.Sprintf("%04d", i)] = b } } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/room_test.go b/modules/mesh-build-go/cmd/mesh-build-go/room_test.go index 40c9853da..3e65c8686 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/room_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/room_test.go @@ -9,7 +9,7 @@ import ( "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/forgekey" "git.novox.be/novox/mesh-sdk/go/health" ) @@ -44,7 +44,7 @@ func TestUnderTheFloorEveryStepIsRefusedAndTheModuleIsUnhealthy(t *testing.T) { if _, err := tool.Run(map[string]any{}); err != nil { t.Fatal(err) } - if st := states.waitEnd(t); st.State != delivery.StepRefused || !strings.HasPrefix(st.Reason, underTheFloor) { + if st := states.waitEnd(t); st.State != sdkstep.StepRefused || !strings.HasPrefix(st.Reason, underTheFloor) { t.Errorf("the long step ended %s: %q, want refused: %q", st.State, st.Reason, underTheFloor) } case "short_probe": @@ -95,7 +95,7 @@ func TestAFailedFetchLeavesNoWorkspace(t *testing.T) { if _, err := fetchTool(t, m)(fetchArgs); err != nil { t.Fatal(err) } - if st := states.waitEnd(t); st.State != delivery.StepFailed { + if st := states.waitEnd(t); st.State != sdkstep.StepFailed { t.Fatalf("the step ended %s, want failed", st.State) } if entries, _ := os.ReadDir(m.Work); len(entries) != 0 { diff --git a/modules/mesh-build-go/cmd/mesh-build-go/run.go b/modules/mesh-build-go/cmd/mesh-build-go/run.go index 13270f6bd..fc11b7998 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/run.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/run.go @@ -2,11 +2,11 @@ package main // **A run ask runs a program built from a change, over heads it reads** (novox/hq ADR 0644, ADR 0656; issue 1921): the // checking program of a group's check. The module builds the program at the commit the ask names, once per -// delivery.RunKey, as a compose ask builds a composer (programOf); fetches every head the ask names and lays each read-only +// sdkstep.RunKey, as a compose ask builds a composer (programOf); fetches every head the ask names and lays each read-only // under the directory MESH_RUN_HEADS names, at `/`; asks the seat verb the ask's input names for it by its // digest (as module.json invokes it; never a read of the asker's state, ADR 0596 §2) and writes it to the file // MESH_RUN_INPUT names; and runs the program shut off with the ask's -// arguments: no network, none of the account. The answer is delivery.RunAnswer: the program's exit, the last line of its +// arguments: no network, none of the account. The answer is sdkstep.RunAnswer: the program's exit, the last line of its // standard output and the first line of its standard error. A program that fails is an answer, not a fault of the step. import ( @@ -19,12 +19,12 @@ import ( "strings" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" + checkerclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // run runs a run ask: the program of its commit, built once, over its heads and its input. -func (m *Module) run(ctx context.Context, fetch buildstep.StepFunc, a delivery.StepAsk) (any, error) { +func (m *Module) run(ctx context.Context, fetch buildstep.StepFunc, a sdkstep.StepAsk) (any, error) { job := *a.Run body, err := m.inputOf(job.Input) if err != nil { @@ -34,7 +34,7 @@ func (m *Module) run(ctx context.Context, fetch buildstep.StepFunc, a delivery.S if err != nil { return nil, err } - key := delivery.RunKey(job.Program, delivery.ToolchainUsed{Version: installed, Recipe: m.Recipe.Version()}, a.Packages) + key := sdkstep.RunKey(job.Program, sdkstep.ToolchainUsed{Version: installed, Recipe: m.Recipe.Version()}, a.Packages) fetchArgs := sdkstep.FetchRequest{Repository: job.Program.Repository, Commit: job.Program.Commit, Packages: a.Packages}.Args() exe, built, err := m.programOf(ctx, fetch, "programs", "checking program", key, fetchArgs, job.Program.Dir, job.Program.Package) if err != nil { @@ -49,27 +49,39 @@ func (m *Module) run(ctx context.Context, fetch buildstep.StepFunc, a delivery.S // step, naming its size, before it is written. if raw, err := json.Marshal(ans); err != nil { return nil, err - } else if len(raw) > delivery.RunInputBound { - return nil, fmt.Errorf("the checking program's answer is %d bytes, over the bound of %d bytes a step key holds", len(raw), delivery.RunInputBound) + } else if len(raw) > sdkstep.RunInputBound { + return nil, fmt.Errorf("the checking program's answer is %d bytes, over the bound of %d bytes a step key holds", len(raw), sdkstep.RunInputBound) } return ans, nil } // inputOf is the input a run names, asked of the seat verb the ask names (module.json invokes it) and checked against // its digest and size: bytes that are not the input named refuse the ask. -func (m *Module) inputOf(in delivery.RunInput) ([]byte, error) { +func (m *Module) inputOf(in sdkstep.RunInput) ([]byte, error) { if m.AskInput == nil { return nil, errors.New("this module asks no seat verb for a run's input") } - raw, err := in.Read(m.AskInput) - if err != nil && raw != nil { + // A verb that does not answer fails the step; an answer that is not the input named refuses the ask. + var askErr error + raw, err := checkerclient.ReadInput(func(key string, body any) (json.RawMessage, error) { + out, err := m.AskInput(key, body) + askErr = err + return out, err + }, in.Digest) + if err != nil { + if askErr != nil { + return nil, err + } return nil, buildstep.Refuse("%v", err) } - return raw, err + if err := in.Verify(raw); err != nil { + return nil, buildstep.Refuse("%v", err) + } + return raw, nil } // placeHead lays one head's commit as a plain directory at dir, with no symlink in it; a test sets its own. -func (m *Module) placeHead(ctx context.Context, h delivery.RunHead, dir string) error { +func (m *Module) placeHead(ctx context.Context, h sdkstep.RunHead, dir string) error { if m.PlaceHead != nil { return m.PlaceHead(ctx, h, dir) } @@ -89,7 +101,7 @@ func (m *Module) placeHead(ctx context.Context, h delivery.RunHead, dir string) } // runProgram lays the heads and the input in a workspace of its own and runs exe shut off over them. -func (m *Module) runProgram(ctx context.Context, exe string, job delivery.RunJob, input []byte) (delivery.RunAnswer, error) { +func (m *Module) runProgram(ctx context.Context, exe string, job sdkstep.RunJob, input []byte) (sdkstep.RunAnswer, error) { id := sdkstep.NewID() done := m.spaces().Hold(id) defer m.releaseSaid(id) @@ -98,15 +110,15 @@ func (m *Module) runProgram(ctx context.Context, exe string, job delivery.RunJob heads := filepath.Join(ws, "heads") for _, h := range job.Heads { if err := m.placeHead(ctx, h, filepath.Join(heads, filepath.FromSlash(h.Repository))); err != nil { - return delivery.RunAnswer{}, fmt.Errorf("the head of %s at %s: %w", h.Repository, h.Commit, err) + return sdkstep.RunAnswer{}, fmt.Errorf("the head of %s at %s: %w", h.Repository, h.Commit, err) } } if err := os.MkdirAll(heads, 0o700); err != nil { - return delivery.RunAnswer{}, err + return sdkstep.RunAnswer{}, err } file := filepath.Join(ws, "input") if err := os.WriteFile(file, input, 0o600); err != nil { - return delivery.RunAnswer{}, err + return sdkstep.RunAnswer{}, err } run := m.Run if !m.Inside { @@ -115,11 +127,11 @@ func (m *Module) runProgram(ctx context.Context, exe string, job delivery.RunJob iso.ReadOnlyInside = []string{heads} // bound after the workspace, so the program cannot change a head run = buildstep.Isolated(m.Run, iso) } - r := run(ctx, []string{delivery.RunHeadsEnv + "=" + heads, delivery.RunInputEnv + "=" + file}, exe, job.Args...) + r := run(ctx, []string{sdkstep.RunHeadsEnv + "=" + heads, sdkstep.RunInputEnv + "=" + file}, exe, job.Args...) if r.Err != "" && r.Status == 0 { - return delivery.RunAnswer{}, fmt.Errorf("the checking program could not run: %s", r.Err) + return sdkstep.RunAnswer{}, fmt.Errorf("the checking program could not run: %s", r.Err) } - ans := delivery.RunAnswer{Exit: r.Status, Said: firstLine(r.Err + "\n" + r.Stderr)} + ans := sdkstep.RunAnswer{Exit: r.Status, Said: firstLine(r.Err + "\n" + r.Stderr)} line := strings.TrimSpace(lastLine(r.Stdout)) switch { case line == "": diff --git a/modules/mesh-build-go/cmd/mesh-build-go/run_integration_test.go b/modules/mesh-build-go/cmd/mesh-build-go/run_integration_test.go index 14d4f8fbf..946928f02 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/run_integration_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/run_integration_test.go @@ -17,7 +17,9 @@ import ( "github.com/nats-io/nats.go/jetstream" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" + checkerclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient" + checker "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/testbus" ) @@ -39,7 +41,7 @@ func TestARunAskIsAnsweredOnTheBusAndItsProgramIsShutOff(t *testing.T) { } ctx, stop := context.WithCancel(context.Background()) defer stop() - if err := buildstep.DeclareAskWorkers(ctx, js, delivery.AskGo, 2*time.Minute); err != nil { + if err := buildstep.DeclareAskWorkers(ctx, js, sdkstep.AskGo, 2*time.Minute); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = js.DeleteStream(context.Background(), "SEAT_NODE_BUILD_GO") }) @@ -59,15 +61,15 @@ func TestARunAskIsAnsweredOnTheBusAndItsProgramIsShutOff(t *testing.T) { t.Fatal(err) } } - m.PlaceHead = func(_ context.Context, h delivery.RunHead, dir string) error { return os.MkdirAll(dir, 0o700) } + m.PlaceHead = func(_ context.Context, h sdkstep.RunHead, dir string) error { return os.MkdirAll(dir, 0o700) } body := `{"v":1}` // The checker's verb input on the bus, and the module asking it as the runtime calls a seat verb it invokes. asked := make(chan string, 4) sub, err := nc.Subscribe("mesh.seat.mesh-delivery-checker.tool.input", func(msg *nats.Msg) { - var ask delivery.RunInputAsk + var ask checker.InputInput _ = json.Unmarshal(msg.Data, &ask) asked <- ask.Digest - reply, _ := json.Marshal(map[string]any{"result": delivery.RunInputAnswer{Digest: ask.Digest, Input: []byte(body)}}) + reply, _ := json.Marshal(map[string]any{"result": checkerclient.InputAnswerOf([]byte(body))}) _ = msg.Respond(reply) }) if err != nil { @@ -90,40 +92,40 @@ func TestARunAskIsAnsweredOnTheBusAndItsProgramIsShutOff(t *testing.T) { } a := runAskOf(t, body) - key := delivery.RunKey(a.Run.Program, delivery.ToolchainUsed{Version: "go1.26.0", Recipe: m.Recipe.Version()}, nil) + key := sdkstep.RunKey(a.Run.Program, sdkstep.ToolchainUsed{Version: "go1.26.0", Recipe: m.Recipe.Version()}, nil) exe := m.programPath("programs", key) os.MkdirAll(filepath.Dir(exe), 0o700) script := "#!/bin/bash\n" + `if touch "$MESH_RUN_HEADS/novox/mesh-sdk/x" 2>/dev/null; then echo '{"wrote":true}'; exit 9; fi; echo "start"; exec 3<>/dev/tcp/127.0.0.1/` + strings.Split(tcp.Addr().String(), ":")[1] + " || exit 7\n" + `echo '{"dialed":true}'` + "\n" if err := os.WriteFile(exe, []byte(script), 0o700); err != nil { t.Fatal(err) } - p := m.Puller(fakeSteps(new([]string)), &buildstep.JetStreamAsks{JS: js, Language: delivery.AskGo}) + p := m.Puller(fakeSteps(new([]string)), &buildstep.JetStreamAsks{JS: js, Language: sdkstep.AskGo}) p.PullWait = 200 * time.Millisecond go p.Pull(ctx) - raw, err := delivery.EncodeStepAsk(a) + raw, err := sdkstep.EncodeStepAsk(a) if err != nil { t.Fatal(err) } - subject, _ := delivery.AskSubject(delivery.AskGo, delivery.AskCheck) + subject, _ := sdkstep.AskSubject(sdkstep.AskGo, sdkstep.AskCheck) if _, err := js.Publish(ctx, subject, raw); err != nil { t.Fatal(err) } - stepKey, _ := delivery.AskStepKey("ace", a.ID) - var step delivery.Step + stepKey, _ := sdkstep.AskStepKey("ace", a.ID) + var step sdkstep.Step for deadline := time.Now().Add(60 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { states.mu.Lock() v := states.steps[stepKey] states.mu.Unlock() - if len(v) > 0 && v[len(v)-1].State != delivery.StepRunning { + if len(v) > 0 && v[len(v)-1].State != sdkstep.StepRunning { step = v[len(v)-1] break } } - if step.State != delivery.StepPassed { + if step.State != sdkstep.StepPassed { t.Fatalf("the run ask ended %q: %s", step.State, step.Reason) } - var ans delivery.RunAnswer + var ans sdkstep.RunAnswer if err := json.Unmarshal(step.Result, &ans); err != nil { t.Fatal(err) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/run_test.go b/modules/mesh-build-go/cmd/mesh-build-go/run_test.go index 31fd4c5a1..a5ca2dd0c 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/run_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/run_test.go @@ -3,6 +3,7 @@ package main import ( "context" "crypto/sha256" + "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -14,53 +15,54 @@ import ( "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" - "git.novox.be/novox/mesh-sdk/go/composer" - "git.novox.be/novox/mesh-sdk/go/delivery" + checkerclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient" + checker "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" + "git.novox.be/novox/mesh-delivery/sdk/composer" "git.novox.be/novox/mesh-sdk/go/forgekey" ) const runCommit = "5555555555555555555555555555555555555555" // runInputVerb is the seat verb a run ask's input is served on, as the checker names it in the ask. -const runInputVerb = "seat:mesh-delivery-checker.input" +const runInputVerb = checkerclient.InputVerb -// answersInput is a seat verb serving body by its digest, as the checker's verb input answers (delivery.RunInputAnswer). +// answersInput is a seat verb serving body by its digest, as the checker's verb input answers (sdkstep.RunInputAnswer). func answersInput(body string) func(key string, b any) (json.RawMessage, error) { return func(key string, b any) (json.RawMessage, error) { - ask, _ := b.(delivery.RunInputAsk) + ask, _ := b.(checker.InputInput) if sum := sha256.Sum256([]byte(body)); key != runInputVerb || ask.Digest != hex.EncodeToString(sum[:]) { return nil, fmt.Errorf("the checker keeps no input %s", ask.Digest) } - return json.Marshal(delivery.RunInputAnswer{Digest: ask.Digest, Input: []byte(body)}) + return json.Marshal(checkerclient.InputAnswerOf([]byte(body))) } } -func runAskOf(t *testing.T, body string) delivery.StepAsk { +func runAskOf(t *testing.T, body string) sdkstep.StepAsk { t.Helper() - in, err := delivery.RunInputOf(runInputVerb, []byte(body)) + in, err := sdkstep.RunInputOf(runInputVerb, []byte(body)) if err != nil { t.Fatal(err) } - return delivery.StepAsk{ID: sdkstep.NewID(), Kind: delivery.AskCheck, Language: delivery.AskGo, Asker: "mesh-delivery-checker", At: time.Now(), - Run: &delivery.RunJob{Program: delivery.RunProgram{Repository: "novox/mesh-delivery", Commit: runCommit, Dir: ".", Package: "./cmd/prog"}, - Heads: []delivery.RunHead{{Repository: "novox/mesh-sdk", Commit: runCommit}, {Repository: "novox/mesh-host", Commit: runCommit}}, + return sdkstep.StepAsk{ID: sdkstep.NewID(), Kind: sdkstep.AskCheck, Language: sdkstep.AskGo, Asker: "mesh-delivery-checker", At: time.Now(), + Run: &sdkstep.RunJob{Program: sdkstep.RunProgram{Repository: "novox/mesh-delivery", Commit: runCommit, Dir: ".", Package: "./cmd/prog"}, + Heads: []sdkstep.RunHead{{Repository: "novox/mesh-sdk", Commit: runCommit}, {Repository: "novox/mesh-host", Commit: runCommit}}, Input: in, Args: []string{"-seats"}}} } // 1921/C1: a kept checking program is run over the input checked against its digest and the heads laid under // MESH_RUN_HEADS at /, and the answer carries its exit and last line; nothing is fetched or built; a // program that fails, or says no JSON, is an answer; an input that is not the one named is refused; and the program was -// kept under delivery.RunKey, so a second ask finds it. +// kept under sdkstep.RunKey, so a second ask finds it. func TestAKeptProgramIsRunOverItsHeadsAndInput(t *testing.T) { m := composeModule(t) body := `{"v":1,"case":"x"}` m.AskInput = answersInput(body) - m.PlaceHead = func(_ context.Context, h delivery.RunHead, dir string) error { + m.PlaceHead = func(_ context.Context, h sdkstep.RunHead, dir string) error { return os.MkdirAll(dir, 0o700) } a := runAskOf(t, body) - key := delivery.RunKey(a.Run.Program, delivery.ToolchainUsed{Version: "go1.26.0", Recipe: m.Recipe.Version()}, nil) + key := sdkstep.RunKey(a.Run.Program, sdkstep.ToolchainUsed{Version: "go1.26.0", Recipe: m.Recipe.Version()}, nil) exe := m.programPath("programs", key) os.MkdirAll(filepath.Dir(exe), 0o700) script := "#!/bin/sh\n" + @@ -75,7 +77,7 @@ func TestAKeptProgramIsRunOverItsHeadsAndInput(t *testing.T) { if err != nil { t.Fatal(err) } - ans := out.(delivery.RunAnswer) + ans := out.(sdkstep.RunAnswer) if ans.Built || ans.Key != key || fetched != 0 || ans.Exit != 3 || ans.Said != "args -seats" || string(ans.Output) != `{"input":{"v":1,"case":"x"},"heads":"novox/mesh-host novox/mesh-sdk "}` { t.Fatalf("answer = %+v (fetched %d) output %s", ans, fetched, ans.Output) @@ -103,12 +105,12 @@ func TestAKeptProgramIsRunOverItsHeadsAndInput(t *testing.T) { heads = i } } - if ws < 0 || heads < ws || !strings.Contains(line, " "+delivery.RunHeadsEnv+" "+argv[heads+1]+" ") { + if ws < 0 || heads < ws || !strings.Contains(line, " "+sdkstep.RunHeadsEnv+" "+argv[heads+1]+" ") { t.Errorf("the heads are not bound read-only over the workspace: %s", line) } // An answer over the bus's value limit fails the step, naming its size. m.Run = func(context.Context, []string, string, ...string) forgekey.Ran { - return forgekey.Ran{Status: 0, Stdout: `"` + strings.Repeat("x", delivery.RunInputBound) + `"`} + return forgekey.Ran{Status: 0, Stdout: `"` + strings.Repeat("x", sdkstep.RunInputBound) + `"`} } if _, err := m.run(context.Background(), fetch, a); err == nil || !strings.Contains(err.Error(), "over the bound") { t.Errorf("an answer over the bound: %v", err) @@ -116,9 +118,9 @@ func TestAKeptProgramIsRunOverItsHeadsAndInput(t *testing.T) { m.Inside, m.Run = true, StepRunner(t.TempDir()) // An input other than the one named is refused, naming it; one the verb does not answer fails the step, saying why. m.AskInput = func(_ string, b any) (json.RawMessage, error) { - return json.Marshal(delivery.RunInputAnswer{Digest: b.(delivery.RunInputAsk).Digest, Input: []byte(`{"other":1}`)}) + return json.Marshal(checker.InputAnswer{Digest: b.(checker.InputInput).Digest, Input: base64.StdEncoding.EncodeToString([]byte(`{"other":1}`))}) } - if _, err := m.run(context.Background(), fetch, a); err == nil || !strings.Contains(err.Error(), "not the one the ask names") || !errors.As(err, new(*buildstep.Refusal)) { + if _, err := m.run(context.Background(), fetch, a); err == nil || !strings.Contains(err.Error(), "whose digest is") || !errors.As(err, new(*buildstep.Refusal)) { t.Errorf("a wrong input: %v", err) } m.AskInput = answersInput(`{"v":2}`) @@ -137,7 +139,7 @@ func TestAComposeAskKeepsItsSeats(t *testing.T) { req := composer.Request{V: composer.Version, Seats: []json.RawMessage{json.RawMessage(`{"seat":"node-x"}`)}, Cases: []composer.Case{{Name: "change", Manifests: map[string]composer.Manifest{"demo": {Digest: digests[0]}}}}} a.Compose.Request, _ = json.Marshal(req) - key := delivery.ComposerKey("novox/mesh-sdk", composeCommit, "go", composer.Package, delivery.ToolchainUsed{Version: "go1.26.0", Recipe: m.Recipe.Version()}, nil) + key := sdkstep.ComposerKey("novox/mesh-sdk", composeCommit, "go", composer.Package, sdkstep.ToolchainUsed{Version: "go1.26.0", Recipe: m.Recipe.Version()}, nil) exe := m.composerPath(key) os.MkdirAll(filepath.Dir(exe), 0o700) script := "#!/bin/sh\n" + `if grep -q '"seats":\[{"seat":"node-x"}\]' "$2"; then S=true; else S=false; fi; ` + @@ -148,7 +150,7 @@ func TestAComposeAskKeepsItsSeats(t *testing.T) { t.Fatal(err) } var v composer.Verdict - if err := json.Unmarshal(out.(delivery.ComposeAnswer).Verdict, &v); err != nil || !v.Cases[0].Manifests["demo"].Accepted { + if err := json.Unmarshal(out.(sdkstep.ComposeAnswer).Verdict, &v); err != nil || !v.Cases[0].Manifests["demo"].Accepted { t.Fatalf("the composer did not receive the seats: %+v %v", v, err) } } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/runner.go b/modules/mesh-build-go/cmd/mesh-build-go/runner.go index 691ef6f58..215204e9a 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/runner.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/runner.go @@ -4,6 +4,7 @@ import ( "context" "os" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -14,7 +15,7 @@ import ( // that base given; a command is bound by its context alone (the bound 0), so a fetch may outlast a tool call's thirty // seconds (the step has its own context). func StepRunner(home string) forgekey.Runner { - run := forgekey.ExecRunnerClean(0) + run := dforgekey.ExecRunnerClean(0) return func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { return run(ctx, append(baseEnv(home), env...), name, args...) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/selected_dirs_test.go b/modules/mesh-build-go/cmd/mesh-build-go/selected_dirs_test.go index 65d3a6aa2..77e8915e9 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/selected_dirs_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/selected_dirs_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // threeModules gives the workspace three go.mod directories, a, b and c, whose modules are fixture/a, fixture/b and @@ -31,7 +31,7 @@ func threeModules(t *testing.T, f *integFake) (*Module, *keptStates) { return m, states } -func runIntegrationOf(t *testing.T, m *Module, states *keptStates, packages ...any) (TestResult, delivery.Step) { +func runIntegrationOf(t *testing.T, m *Module, states *keptStates, packages ...any) (TestResult, sdkstep.Step) { t.Helper() for _, tool := range m.Tools(m.StepTools()) { if tool.Name != "build_go_test" { @@ -47,7 +47,7 @@ func runIntegrationOf(t *testing.T, m *Module, states *keptStates, packages ...a } st := states.waitEnd(t) var res TestResult - if st.State == delivery.StepPassed { + if st.State == sdkstep.StepPassed { if err := json.Unmarshal(st.Result, &res); err != nil { t.Fatal(err) } @@ -60,7 +60,7 @@ func runIntegrationOf(t *testing.T, m *Module, states *keptStates, packages ...a func TestOnlyTheSelectedDirectoryCompilesAndRuns(t *testing.T) { f := newIntegFake(t, "slow") m, states := threeModules(t, f) - if _, st := runIntegrationOf(t, m, states, "fixture/b/x"); st.State != delivery.StepPassed { + if _, st := runIntegrationOf(t, m, states, "fixture/b/x"); st.State != sdkstep.StepPassed { t.Fatalf("the step ended %s: %s", st.State, st.Reason) } tree := filepath.Join(m.Work, "ws1", "tree") @@ -131,7 +131,7 @@ func TestSelectedDirectoriesRunAtOnceAndAnswerInDirectoryOrder(t *testing.T) { m, states := threeModules(t, f) m.Parallel = 3 res, st := runIntegrationOf(t, m, states, "fixture/c/x", "fixture/a/x") - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("the step ended %s: %s", st.State, st.Reason) } if most != 2 { diff --git a/modules/mesh-build-go/cmd/mesh-build-go/store_integration_test.go b/modules/mesh-build-go/cmd/mesh-build-go/store_integration_test.go index 1a2fa7aec..f7434ad9a 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/store_integration_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/store_integration_test.go @@ -3,12 +3,16 @@ package main import ( + "bytes" "context" "crypto/sha256" "encoding/hex" + "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" + "net/url" "os" "os/exec" "path/filepath" @@ -19,7 +23,10 @@ import ( "github.com/distribution/distribution/v3/registry/handlers" _ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" + artifactref "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient/reference" + registry "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" ) // realStore starts the artifact store's own program (the distribution registry, modules/distribution) inside this @@ -34,23 +41,68 @@ func realStore(t *testing.T, m *Module) string { srv := httptest.NewServer(handlers.NewApp(context.Background(), config)) t.Cleanup(srv.Close) addr := strings.TrimPrefix(srv.URL, "http://") - m.RegistryFile = filepath.Join(t.TempDir(), "registry") - if err := os.WriteFile(m.RegistryFile, []byte(addr+"\n"), 0o644); err != nil { - t.Fatal(err) + // The seat's holder as distribution holds it: the publish through the seat's verbs (publish.go, played by withStore), + // each whole part then stored in the store under / by its digest. + s := withStore(t, m) + inner := m.Call + m.Call = func(key string, body any) (json.RawMessage, error) { + raw, err := inner(key, body) + var a registry.PublishPartAnswer + if err != nil || !strings.HasSuffix(key, ".publish-part") || json.Unmarshal(raw, &a) != nil || !a.Complete { + return raw, err + } + s.mu.Lock() + defer s.mu.Unlock() + for k, b := range s.blobs { + name, digest, _ := strings.Cut(k, "@") + if err := uploadBlob(srv.URL, name, digest, b); err != nil { + return nil, err + } + delete(s.blobs, k) + } + return raw, nil } - m.Push = nil return addr } -// resolve reads a done artifact's reference from the store, as the controller will: artifact-store:/// -// /blobs/ is /v2///blobs/. -func resolve(t *testing.T, addr string, a delivery.BuiltArtifact) []byte { - t.Helper() - path, ok := strings.CutPrefix(a.Reference, "artifact-store://") - if !ok { - t.Fatalf("%s's reference %q is not the artifact store's", a.Name, a.Reference) +// uploadBlob is the holder's own write into its store: an upload opened, then its bytes put by their digest (the +// distribution protocol's two requests), as distribution stores a part it took whole. +func uploadBlob(base, name, digest string, body []byte) error { + resp, err := http.Post(base+"/v2/"+name+"/blobs/uploads/", "", nil) + if err != nil { + return err } - resp, err := http.Get("http://" + addr + "/v2/" + path) + resp.Body.Close() + loc, err := url.Parse(resp.Header.Get("Location")) + if err != nil || resp.StatusCode != http.StatusAccepted { + return fmt.Errorf("the store opened no upload of %s: %s %v", digest, resp.Status, err) + } + b, _ := url.Parse(base) + u := b.ResolveReference(loc) + q := u.Query() + q.Set("digest", digest) + u.RawQuery = q.Encode() + req, _ := http.NewRequest(http.MethodPut, u.String(), bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/octet-stream") + put, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + put.Body.Close() + if put.StatusCode != http.StatusCreated { + return fmt.Errorf("the store did not take %s: %s", digest, put.Status) + } + return nil +} + +// resolve reads a done artifact's reference from the store as a node-engine does (registryForms.Routed): the seat's form +// oci-artifact-registry//@ is /v2///blobs/ (ADR 0605 §9). +func resolve(t *testing.T, addr string, a builds.BuiltArtifact) []byte { + t.Helper() + if _, _, ok := registryForms.InRegistrySeat(a.Reference); !ok { + t.Fatalf("%s's reference %q is not in the form a registry seat answers", a.Name, a.Reference) + } + resp, err := http.Get(registryForms.Routed(a.Reference, addr)) if err != nil { t.Fatal(err) } @@ -85,23 +137,23 @@ func TestADoneBuildsArtifactsResolveInARealArtifactStore(t *testing.T) { m := &Module{Home: home, Work: work, Cache: t.TempDir()} m.Run = StepRunner(home) addr := realStore(t, m) - done := delivery.BuilderBuild{V: 1, State: delivery.BuildDone, Manifest: strings.Repeat("0", 64)} + done := builds.BuilderBuild{V: 1, State: builds.BuildDone, Manifest: strings.Repeat("0", 64)} for _, a := range []struct{ name, kind string }{{"tools", "bundle"}, {"files", "archive"}} { out, err := m.build(context.Background(), map[string]any{"workspace": "ws1", "module": ".", "artifact": a.name}) if err != nil { t.Fatalf("%s: %v", a.name, err) } r := out.(BuildResult) - done.Artifacts = append(done.Artifacts, delivery.BuiltArtifact{Name: a.name, Kind: a.kind, Digest: r.Digest, Reference: r.Reference}) + done.Artifacts = append(done.Artifacts, builds.BuiltArtifact{Name: a.name, Kind: a.kind, Digest: r.Digest, Reference: r.Reference}) } - if _, err := delivery.EncodeBuilderBuild(done); err != nil { + if _, err := builds.EncodeBuilderBuild(done); err != nil { t.Errorf("the done key the builder would write is refused: %v", err) } if ok, missing := done.Referenced(); !ok { t.Errorf("the done key is not deliverable, missing %v", missing) } for _, a := range done.Artifacts { - if !strings.HasSuffix(a.Reference, "/blobs/"+a.Digest) { + if !strings.HasSuffix(a.Reference, "@"+a.Digest) { t.Errorf("%s: the reference %q does not end in its digest %s", a.Name, a.Reference, a.Digest) } sum := sha256.Sum256(resolve(t, addr, a)) @@ -110,3 +162,6 @@ func TestADoneBuildsArtifactsResolveInARealArtifactStore(t *testing.T) { } } } + +// registryForms reads a reference of the registry form, the two seats' names from the build step (their contracts). +var registryForms = artifactref.Seats{Image: buildstep.ImageSeat, Object: buildstep.ArtifactSeat} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/test.go b/modules/mesh-build-go/cmd/mesh-build-go/test.go index f02dcc07c..f9dcccb74 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/test.go @@ -15,8 +15,8 @@ import ( "sync" "time" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -40,8 +40,8 @@ func (m *Module) testTool() StepTool { "packages": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "package patterns of each module, ./... when none"}, "run": map[string]any{"type": "string", "description": "optional go test -run pattern: only the tests it matches run (a try's tests)"}, - delivery.NodeNamesArg: map[string]any{"type": "array", "items": map[string]any{"type": "string"}, - "description": "optional, the names of the mesh's nodes: the tests get them comma-separated in " + delivery.NodeNamesEnv + " (a check that keeps node names out of the repository reads them there)"}, + sdkstep.NodeNamesArg: map[string]any{"type": "array", "items": map[string]any{"type": "string"}, + "description": "optional, the names of the mesh's nodes: the tests get them comma-separated in " + sdkstep.NodeNamesEnv + " (a check that keeps node names out of the repository reads them there)"}, "runner": runnerProperty, }, "required": []string{"workspace", "level"}, @@ -81,7 +81,7 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) { if err != nil { return nil, err } - names, err := nodeNamesArg(args[delivery.NodeNamesArg]) + names, err := nodeNamesArg(args[sdkstep.NodeNamesArg]) if err != nil { return nil, err } @@ -100,9 +100,41 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) { return nil, err } res.JudgedBy = RunnerInstalled + if level == LevelUnit { + if err := compareGenerated(tree, res); err != nil { + return nil, err + } + } return res, nil } +// compareGenerated regenerates the generated Go files of each contracts module in the tree (every module.json in the tree, in +// any folder: ADR 0634 §8; CAT-BG5) and fails the level on any out of step, Why naming each path. +func compareGenerated(tree string, res *TestResult) error { + dirs, err := buildstep.ModuleDirs(tree) + if err != nil { + return err + } + for _, dir := range dirs { + paths, err := buildstep.OutOfStep(dir, buildstep.GeneratedRootGo) + if err != nil { + return err + } + rel, _ := filepath.Rel(tree, dir) + for _, p := range paths { + res.OutOfStep = append(res.OutOfStep, filepath.ToSlash(filepath.Join(rel, p))) + } + } + if len(res.OutOfStep) > 0 { + why := "generated files out of step with the seat generator (regenerate them): " + strings.Join(res.OutOfStep, ", ") + if res.Why != "" { + why = res.Why + "; " + why + } + res.Passed, res.Why = false, why + } + return nil +} + // testAsCandidate runs a level as the candidate's program (ADR 0535 §1): the integration level is refused first as the // installed program would refuse it (no postgres), then the whole level runs in the candidate's one shut-off process. func (m *Module) testAsCandidate(ctx context.Context, workspace, level string, packages []string, pattern string, names []string) (*TestResult, error) { @@ -140,11 +172,11 @@ func (m *Module) besideEnv(workspace string) []string { const BuildNodeEnv = "MESH_BUILD_NODE" // checkEnv is what every level hands the tests it runs: the build node mark, the siblings (besideEnv) and, when the -// ask carried them, the mesh's node names (delivery.NodeNamesEnv, novox/hq ADR 0567 §12). +// ask carried them, the mesh's node names (sdkstep.NodeNamesEnv, novox/hq ADR 0567 §12). func (m *Module) checkEnv(workspace string, names []string) []string { env := append([]string{BuildNodeEnv + "=1"}, m.besideEnv(workspace)...) if len(names) > 0 { - env = append(env, delivery.NodeNamesEnv+"="+strings.Join(names, ",")) + env = append(env, sdkstep.NodeNamesEnv+"="+strings.Join(names, ",")) } return env } @@ -161,13 +193,13 @@ func nodeNamesArg(raw any) ([]string, error) { for _, e := range v { n, ok := e.(string) if !ok || n == "" || strings.ContainsAny(n, ", \n") { - return nil, fmt.Errorf("%s is a list of node names, not %v", delivery.NodeNamesArg, e) + return nil, fmt.Errorf("%s is a list of node names, not %v", sdkstep.NodeNamesArg, e) } out = append(out, n) } return out, nil } - return nil, fmt.Errorf("%s is a list of node names", delivery.NodeNamesArg) + return nil, fmt.Errorf("%s is a list of node names", sdkstep.NodeNamesArg) } // packagesArg reads the packages argument: a list of package patterns, as []any when it came over the bus as JSON and diff --git a/modules/mesh-build-go/cmd/mesh-build-go/test_test.go b/modules/mesh-build-go/cmd/mesh-build-go/test_test.go index aa6d1d1a7..bd241bb20 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/test_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/test_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -53,7 +53,7 @@ func workspaceFor(t *testing.T, captured string) (*Module, *keptStates, string, return m, states, "ws1", calls } -func runUnit(t *testing.T, m *Module, states *keptStates, ws string) (TestResult, delivery.Step) { +func runUnit(t *testing.T, m *Module, states *keptStates, ws string) (TestResult, sdkstep.Step) { t.Helper() var run func(map[string]any) (any, error) for _, tool := range m.Tools(m.StepTools()) { @@ -66,7 +66,7 @@ func runUnit(t *testing.T, m *Module, states *keptStates, ws string) (TestResult } st := states.waitEnd(t) var res TestResult - if st.State == delivery.StepPassed { + if st.State == sdkstep.StepPassed { if err := json.Unmarshal(st.Result, &res); err != nil { t.Fatalf("result %s: %v", st.Result, err) } @@ -79,7 +79,7 @@ func runUnit(t *testing.T, m *Module, states *keptStates, ws string) (TestResult func TestUnitLevelNamesTheFailingTestAndFailsTheLevel(t *testing.T) { m, states, ws, _ := workspaceFor(t, "failing") res, st := runUnit(t, m, states, ws) - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("the step ended %s: %s", st.State, st.Reason) } if res.Passed || res.Level != "unit" { @@ -109,7 +109,7 @@ func TestUnitLevelNamesTheFailingTestAndFailsTheLevel(t *testing.T) { func TestUnitLevelNamesEveryFailingTestOfEveryPackage(t *testing.T) { m, states, ws, calls := workspaceFor(t, "twofailing") res, st := runUnit(t, m, states, ws) - if st.State != delivery.StepPassed || res.Passed { + if st.State != sdkstep.StepPassed || res.Passed { t.Fatalf("step %s (%s), result %+v: want the step done and the level failed", st.State, st.Reason, res) } var got []string @@ -173,11 +173,78 @@ func TestUnitLevelOfAFixtureWithOnlyAnIntegrationTestHasNoTests(t *testing.T) { func TestUnitLevelOfAFixtureWithNoTestsSaysSo(t *testing.T) { m, states, ws, _ := workspaceFor(t, "empty") res, st := runUnit(t, m, states, ws) - if st.State != delivery.StepPassed || !res.Passed || !res.NoTests || res.Why != "the repository has no tests at the unit level" || len(res.Failed) != 0 { + if st.State != sdkstep.StepPassed || !res.Passed || !res.NoTests || res.Why != "the repository has no tests at the unit level" || len(res.Failed) != 0 { t.Errorf("result = %+v (%s)", res, st.State) } } +// CAT-BG5: a contracts module (a package artifact naming generated-from) whose generated Go files are out of step with +// the seat generator fails the unit level, each path named in its out-of-step and its why; its TypeScript is not this +// module's to compare. +func TestUnitLevelOfAContractsModuleOutOfStepFailsNamingEachPath(t *testing.T) { + m, states, ws, _ := workspaceFor(t, "empty") + tree := filepath.Join(m.Work, ws, "tree") + for p, text := range map[string]string{ + "module.json": `{"module":"c","build":{"artifacts":[{"name":"go","kind":"package","language":"go","paths":["go"],"generated-from":"."}]}}`, + "seats/one-store/seat.json": `{"format":1,"seat":"one-store","scope":"mesh","grain":"seat-list","domain":"Module","decision":"ADR 1","verbs":[{"name":"query","input":{"type":"object"}}]}`, + "go/go.mod": "module example.org/c/go\n", + "go/stray.go": "package stray\n", + "ts/package.json": `{"name":"@example/c"}`, + "ts/stray.ts": "// not this module's\n", + } { + full := filepath.Join(tree, filepath.FromSlash(p)) + _ = os.MkdirAll(filepath.Dir(full), 0o755) + if err := os.WriteFile(full, []byte(text), 0o644); err != nil { + t.Fatal(err) + } + } + res, st := runUnit(t, m, states, ws) + stray := false + for _, p := range res.OutOfStep { + stray = stray || p == "go/stray.go" + if !strings.HasPrefix(p, "go/") { + t.Errorf("%s is not under the Go root", p) + } + } + if st.State != sdkstep.StepPassed || res.Passed || !stray || !strings.Contains(res.Why, "go/stray.go") { + t.Errorf("result = %+v (%s %s)", res, st.State, st.Reason) + } +} + +// ADR 0634 §8: the same for a contracts module in a folder of a tree whose root holds a module of its own (found by its +// manifest, not by lying under modules/); a contracts module (a package artifact naming generated-from) whose generated Go files are out of step with +// the seat generator fails the unit level, each path named in its out-of-step and its why; its TypeScript is not this +// module's to compare. +func TestUnitLevelOfAContractsModuleInAFolderOutOfStepFailsNamingEachPath(t *testing.T) { + m, states, ws, _ := workspaceFor(t, "empty") + tree := filepath.Join(m.Work, ws, "tree") + for p, text := range map[string]string{ + "module.json": `{"module":"c","build":{"artifacts":[{"name":"go","kind":"package","language":"go","paths":["go"],"generated-from":"."}]}}`, + "seats/one-store/seat.json": `{"format":1,"seat":"one-store","scope":"mesh","grain":"seat-list","domain":"Module","decision":"ADR 1","verbs":[{"name":"query","input":{"type":"object"}}]}`, + "go/go.mod": "module example.org/c/go\n", + "go/stray.go": "package stray\n", + "ts/package.json": `{"name":"@example/c"}`, + "ts/stray.ts": "// not this module's\n", + } { + full := filepath.Join(tree, "domain", filepath.FromSlash(p)) + _ = os.MkdirAll(filepath.Dir(full), 0o755) + if err := os.WriteFile(full, []byte(text), 0o644); err != nil { + t.Fatal(err) + } + } + res, st := runUnit(t, m, states, ws) + stray := false + for _, p := range res.OutOfStep { + stray = stray || p == "domain/go/stray.go" + if !strings.HasPrefix(p, "domain/go/") { + t.Errorf("%s is not under the folder's Go root", p) + } + } + if st.State != sdkstep.StepPassed || res.Passed || !stray || !strings.Contains(res.Why, "domain/go/stray.go") { + t.Errorf("result = %+v (%s %s)", res, st.State, st.Reason) + } +} + // A package that does not build fails the level with the compiler's words. func TestUnitLevelOfAPackageThatDoesNotBuildFailsWithTheCompilersLine(t *testing.T) { m, states, ws, _ := workspaceFor(t, "broken") @@ -224,7 +291,7 @@ func TestAGoTestThatCouldNotRunEndsTheStepFailed(t *testing.T) { return forgekey.Ran{Stdout: "go.mod\n"} }, &[]shutOff{}) _, st := runUnit(t, m, states, ws) - if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "go: cannot find main module") { + if st.State != sdkstep.StepFailed || !strings.Contains(st.Reason, "go: cannot find main module") { t.Errorf("the step ended %s: %q", st.State, st.Reason) } } @@ -245,7 +312,7 @@ func TestATestOfAnUnknownWorkspaceOrAnotherLevelIsRefused(t *testing.T) { {"workspace": "ws1", "level": "unit", "packages": []any{"../x"}}, } { run(args) - if st := states.waitEnd(t); st.State != delivery.StepFailed { + if st := states.waitEnd(t); st.State != sdkstep.StepFailed { t.Errorf("%v: the step ended %s", args, st.State) } } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/versions.go b/modules/mesh-build-go/cmd/mesh-build-go/versions.go index eb00ca5e2..4d182addd 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/versions.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/versions.go @@ -2,7 +2,7 @@ package main // The asked versions of the mesh's packages (novox/hq ADR 0573 §2, §4; issue 1585): every module builds against the latest // published version of each mesh package it uses, and a delivery group's check against the group's own heads, whatever -// version its go.mod names. The builder and the checker name each version in the ask (delivery.StepAsk.Packages, a full +// version its go.mod names. The builder and the checker name each version in the ask (sdkstep.StepAsk.Packages, a full // commit by package); before Go downloads anything, the fetch writes them into the temporary -modfile of each directory // whose go.mod requires the package (pins.go), so the fetch's go commands, the build and the tests all take them: // @@ -10,7 +10,11 @@ package main // written beside it, which go mod verify then checks the downloaded module against; // - a commit Go cannot fetch (the head of a group's pull request) is found among the forge's pull request heads and // placed beside the workspace, and the modfile replaces the package by it, as a pin on a head is (pins.go); -// - a version go.mod names is the oldest the module accepts: one newer than the asked is built, and said loudly. +// - a version go.mod names is the oldest the module accepts: one newer than the asked is built, and said loudly; +// - a semantic version (a delivery group's tagged version, `v0.1.15-feat-x.2`: novox/hq ADR 0607 §3, §5, design 51 +// *Tagged versions*) is fetched by that version through the Go seat and built whatever go.mod names, since the +// group's check is of that version; one the Go seat does not hold fails the fetch naming it, and is never looked for +// among the pull request heads. // // Each package's outcome is named in the module's log and in the fetch's answer (FetchResult.Packages). @@ -26,8 +30,10 @@ import ( "strconv" "strings" "sync" + "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" + "git.novox.be/novox/mesh-sdk/go/forgekey" ) // TakenVersion is what the fetch built a directory against for one asked package: the asked commit, the version its go.mod @@ -54,8 +60,8 @@ type downloadInfo struct { // takeVersions writes the asked versions into the modfile of dir (d in the tree) and answers what it took, one entry per // asked package its go.mod requires. A package go.mod replaces is built as go.mod says, and said. A version that can be // neither fetched nor found among the forge's pull request heads fails the fetch, naming it. pins serialises the -// placements a workspace's directories share. -func (m *Module) takeVersions(ctx context.Context, root, dir, d string, asked delivery.PackageVersions, pins *sync.Mutex) ([]TakenVersion, error) { +// laid copies a workspace's directories share. +func (m *Module) takeVersions(ctx context.Context, root, dir, d string, asked sdkstep.PackageVersions, pins *sync.Mutex) ([]TakenVersion, error) { raw, err := os.ReadFile(filepath.Join(dir, "go.mod")) if err != nil { return nil, nil @@ -91,6 +97,39 @@ func saidSuffix(said string) string { return ": " + said } +// downloadAttempts bounds the downloads of one asked version, and downloadBackoff is the wait after the first failed one +// (twice that after the second): a network failure is taken again, any other fails at once. +const ( + downloadAttempts = 3 + downloadBackoff = 3 * time.Second +) + +// networkFailure says go's words are those of a network that did not answer (a name lookup, a timeout, a refused or +// reset connection), not of a version or a checksum that is wrong. +func networkFailure(s string) bool { + s = strings.ToLower(s) + for _, w := range []string{"i/o timeout", "no such host", "temporary failure in name resolution", "connection refused", "connection reset", "tls handshake timeout", "lookup "} { + if strings.Contains(s, w) { + return true + } + } + return false +} + +// retryWait waits d (m.Pause in a test) or until ctx ends. +func (m *Module) retryWait(ctx context.Context, d time.Duration) error { + if m.Pause != nil { + m.Pause(d) + return ctx.Err() + } + select { + case <-time.After(d): + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + // takeVersion takes one asked version into dir's modfile (takeVersions). func (m *Module) takeVersion(ctx context.Context, root, dir string, tv *TakenVersion, pins *sync.Mutex) error { modfile, err := m.ensureModfile(dir) @@ -98,10 +137,37 @@ func (m *Module) takeVersion(ctx context.Context, root, dir string, tv *TakenVer return err } pinsDir := filepath.Dir(modfile) - r := m.Run(ctx, m.goEnv(), "go", "-C", pinsDir, "mod", "download", "-json", tv.Package+"@"+tv.Asked) + tagged := sdkstep.PackageSemver.MatchString(tv.Asked) + var r forgekey.Ran var info downloadInfo - _ = json.Unmarshal([]byte(r.Stdout), &info) - if r.Status != 0 || r.Err != "" || info.Version == "" || info.Error != "" { + var failed bool + attempts := 0 + for { + attempts++ + r = m.Run(ctx, m.goEnv(), "go", "-C", pinsDir, "mod", "download", "-json", tv.Package+"@"+tv.Asked) + info = downloadInfo{} + _ = json.Unmarshal([]byte(r.Stdout), &info) + failed = r.Status != 0 || r.Err != "" || info.Version == "" || info.Error != "" + if !failed || attempts >= downloadAttempts || !networkFailure(info.Error+"\n"+r.Err+"\n"+r.Stderr) { + break + } + // A name lookup, a timeout or a refused connection says nothing of the version: the download is taken again. + if m.Say != nil { + m.Say("%s: downloading %s@%s failed on the network (attempt %d of %d), taken again: %s", filepath.Base(dir), tv.Package, tv.Asked, attempts, downloadAttempts, causeLine(info.Error+"\n"+r.Err+"\n"+r.Stderr)) + } + if err := m.retryWait(ctx, time.Duration(attempts)*downloadBackoff); err != nil { + return err + } + } + tried := "taken once" + if attempts > 1 { + tried = fmt.Sprintf("taken %d times", attempts) + } + if failed && tagged { + failure := strings.TrimSpace(info.Error + "\n" + r.Err + "\n" + r.Stderr) + return fmt.Errorf("downloading the group's tagged version %s of %s through the Go seat failed (a download failure, %s, that may be retaken by a new ask of this fingerprint): %s", tv.Asked, tv.Package, tried, causeLine(failure)) + } + if failed { // Not a commit Go can fetch: the head of a group's pull request (ADR 0573 §4), placed from the forge's heads. // Whether it is one is the forge's fact (placePin), whatever go said. failure := strings.TrimSpace(info.Error + "\n" + r.Err + "\n" + r.Stderr) @@ -117,7 +183,7 @@ func (m *Module) takeVersion(ctx context.Context, root, dir string, tv *TakenVer pins.Unlock() // On no head, and go failed for another cause (the forge or the resolver did not answer): it fails with go's own // words, and not as a kind the builder holds until the module changes (issue 1748). - return fmt.Errorf("downloading the asked version %s of %s failed (a download failure that may be retaken): %s", tv.Asked, tv.Package, causeLine(failure)) + return fmt.Errorf("downloading the asked version %s of %s failed (a download failure, %s, that may be retaken by a new ask of this fingerprint): %s", tv.Asked, tv.Package, tried, causeLine(failure)) } if err == nil { err = m.replaceInModfile(dir, tv.Package, where) @@ -129,7 +195,9 @@ func (m *Module) takeVersion(ctx context.Context, root, dir string, tv *TakenVer tv.Built, tv.Said = tv.Asked, "Go cannot fetch it: placed from the pull request's head, which has no checksum" return nil } - if compareVersions(tv.Named, info.Version) > 0 { + if tagged && compareVersions(tv.Named, info.Version) > 0 { + tv.Said = fmt.Sprintf("go.mod names %s, newer than the group's tagged version %s, which is built: the group's check is of its tagged version (ADR 0607 §3)", tv.Named, info.Version) + } else if compareVersions(tv.Named, info.Version) > 0 { // A version go.mod names is the oldest the module accepts (ADR 0573 §2): said loudly, and go.mod's is built. tv.Built = tv.Named tv.Said = fmt.Sprintf("go.mod names %s, newer than the asked %s, so go.mod's is built (a version a repository names is the oldest it accepts, ADR 0573 §2)", tv.Named, info.Version) diff --git a/modules/mesh-build-go/cmd/mesh-build-go/versions_test.go b/modules/mesh-build-go/cmd/mesh-build-go/versions_test.go index 3d28eafda..e24c6415b 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/versions_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/versions_test.go @@ -7,8 +7,10 @@ import ( "path/filepath" "strings" "testing" + "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -27,6 +29,7 @@ func TestAnAskedVersionIsWrittenIntoTheModfileWhateverGoModNames(t *testing.T) { built, said, edit string failsWith string // a download failure that is not an unknown revision: the step fails with go's own words forgeDown bool // the forge's listing of pull request heads fails too + timeouts, downloads int // downloads that time out before go answers; the downloads asked in all (0: not counted) }{ {name: "a trunk commit newer than go.mod's", asked: trunk, answer: `{"Path":"` + sdk + `","Version":"v0.1.15-0.20261101000000-aaaaaaaaaaaa","Sum":"h1:zip=","GoModSum":"h1:mod="}`, @@ -50,12 +53,33 @@ func TestAnAskedVersionIsWrittenIntoTheModfileWhateverGoModNames(t *testing.T) { {name: "a forge that cannot be listed is retaken", asked: commitSHA, failing: true, forgeDown: true, failsWith: "unable to access the forge", answer: `{"Path":"` + sdk + `","Error":"invalid version: unknown revision ` + pinShort + `"}`}, + // ADR 0607 §3, §5 (design 51 *Tagged versions*): a group's tagged version is fetched by that version and built + // whatever go.mod names; one the Go seat lacks fails naming it, never looked for among the pull request heads. + {name: "a group's tagged version", asked: "v0.1.15-feat-x.2", + answer: `{"Path":"` + sdk + `","Version":"v0.1.15-feat-x.2","Sum":"h1:zip=","GoModSum":"h1:mod="}`, + built: "v0.1.15-feat-x.2", edit: "-require=" + sdk + "@v0.1.15-feat-x.2"}, + {name: "a group's tagged version older than go.mod's", asked: "v0.1.14-feat-x.2", + answer: `{"Path":"` + sdk + `","Version":"v0.1.14-feat-x.2","Sum":"h1:zip=","GoModSum":"h1:mod="}`, + built: "v0.1.14-feat-x.2", said: "the group's tagged version", edit: "-require=" + sdk + "@v0.1.14-feat-x.2"}, + {name: "a tagged version the Go seat lacks", asked: "v0.1.15-feat-x.3", failing: true, failsWith: "the group's tagged version v0.1.15-feat-x.3", + answer: `{"Path":"` + sdk + `","Error":"` + sdk + `@v0.1.15-feat-x.3: reading https://seat/go/@v/v0.1.15-feat-x.3.info: 404 Not Found"}`}, + // Issue 1966: a network failure is taken again a bounded number of times; another failure is not. + {name: "a timeout twice is taken again", asked: trunk, timeouts: 2, downloads: 3, + answer: `{"Path":"` + sdk + `","Version":"v0.1.15-0.20261101000000-aaaaaaaaaaaa","Sum":"h1:zip=","GoModSum":"h1:mod="}`, + built: "v0.1.15-0.20261101000000-aaaaaaaaaaaa", edit: "-require=" + sdk + "@v0.1.15-0.20261101000000-aaaaaaaaaaaa"}, + {name: "a timeout that stays fails after three downloads", asked: offHead, failing: true, failsWith: "taken 3 times", downloads: 3, + answer: `{"Path":"` + sdk + `","Error":"lookup git.novox.be on 10.10.0.3:53: i/o timeout"}`}, + {name: "an unknown revision is not taken again", asked: offHead, failing: true, failsWith: "on no pull request head", downloads: 1, + answer: `{"Path":"` + sdk + `","Error":"invalid version: unknown revision ` + pinShort + `"}`}, {name: "a refused connection is no pin", asked: offHead, failing: true, failsWith: "connection refused", answer: `{"Path":"` + sdk + `","Error":"git ls-remote -q origin in /tmp/gopath: dial tcp 10.10.0.1:443: connect: connection refused"}`}, } { t.Run(c.name, func(t *testing.T) { h, states := newPinHarness(t, pinnedGoMod, false, pullList) h.f.gomods = "go.mod\nother/go.mod\n" + var paused []time.Duration + h.m.Pause = func(d time.Duration) { paused = append(paused, d) } + downloads := 0 base := h.m.Run h.m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { line := name + " " + strings.Join(args, " ") @@ -78,6 +102,10 @@ func TestAnAskedVersionIsWrittenIntoTheModfileWhateverGoModNames(t *testing.T) { h.mu.Lock() h.cmds = append(h.cmds, cmdRecord{env: env, line: line}) h.mu.Unlock() + downloads++ + if downloads <= c.timeouts { + return forgekey.Ran{Status: 1, Stdout: `{"Path":"` + sdk + `","Error":"lookup git.novox.be on 10.10.0.3:53: i/o timeout"}`} + } if c.failing { return forgekey.Ran{Status: 1, Stdout: c.answer} } @@ -90,14 +118,23 @@ func TestAnAskedVersionIsWrittenIntoTheModfileWhateverGoModNames(t *testing.T) { t.Fatal(err) } st := states.waitEnd(t) + if c.downloads != 0 && (downloads != c.downloads || len(paused) != c.downloads-1) { + t.Errorf("go downloaded %d times with %d waits, want %d downloads", downloads, len(paused), c.downloads) + } + if c.failsWith == "on no pull request head" { // not a network failure: the forge's heads said, no retaking + if st.State != sdkstep.StepFailed || !strings.Contains(st.Reason, c.failsWith) { + t.Errorf("the unknown revision ended %s: %q", st.State, st.Reason) + } + return + } if c.failsWith != "" { - if st.State != delivery.StepFailed || !strings.Contains(st.Reason, c.failsWith) || strings.Contains(st.Reason, "no pull request head") || - !strings.Contains(st.Reason, "may be retaken") || delivery.FailedUntilChanged(st.Reason) { + if st.State != sdkstep.StepFailed || !strings.Contains(st.Reason, c.failsWith) || strings.Contains(st.Reason, "no pull request head") || + !strings.Contains(st.Reason, "may be retaken") || builds.FailedUntilChanged(st.Reason) { t.Errorf("the download failure ended %s: %q", st.State, st.Reason) } return } - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("the step ended %s: %s", st.State, st.Reason) } var res FetchResult diff --git a/modules/mesh-build-go/cmd/mesh-build-go/workspace_test.go b/modules/mesh-build-go/cmd/mesh-build-go/workspace_test.go new file mode 100644 index 000000000..49990aeb5 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/workspace_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +// Issue 1921 (the fourth group try): a workspace whose members require each other at the zero version loads its module +// graph with no lookup, so with nothing fetched (GOPROXY=off), as a composer built beside the group's heads does. +func TestAWorkspaceOfTheGroupResolvesTheZeroVersionWithNoLookup(t *testing.T) { + root := t.TempDir() + mod := func(name, body string) string { + dir := filepath.Join(root, name) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return dir + } + a := mod("a", "module git.novox.be/novox/mesh-host\n\ngo 1.26.0\n\nrequire git.novox.be/novox/mesh-contracts/go/x "+zeroVersion+"\n") + b := mod("b", "module git.novox.be/novox/mesh-contracts/go/x\n\ngo 1.26.0\n") + // The group check of 8a80e8e86858: a module beside requiring one no tree holds is not laid, so nothing looks it up. + c := mod("c", "module git.novox.be/novox/mesh-delivery/modules/g\n\ngo 1.26.0\n\nrequire (\n\tgit.novox.be/novox/mesh-catalog/modules/d "+zeroVersion+"\n)\n") + work, err := workspaceOf(a, []string{b, c, b}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(work, c) { + t.Errorf("the workspace lays %s, which the composer does not require:\n%s", c, work) + } + if err := os.WriteFile(filepath.Join(root, "go.work"), []byte(work), 0o600); err != nil { + t.Fatal(err) + } + cmd := exec.Command("go", "list", "-m", "all") + cmd.Dir = a + cmd.Env = append(os.Environ(), "GOWORK="+filepath.Join(root, "go.work"), "GOPROXY=off", "GOFLAGS=", "GOTOOLCHAIN=local") + if out, err := cmd.CombinedOutput(); err != nil || !strings.Contains(string(out), "git.novox.be/novox/mesh-contracts/go/x") { + t.Fatalf("the workspace's module graph does not load with nothing fetched: %v\n%s\n%s", err, out, work) + } + // A requirement at the zero version no tree holds is said before Go looks it up, naming it. + if _, err := workspaceOf(c, []string{a, b}); err == nil || !strings.Contains(err.Error(), "mesh-catalog/modules/d") { + t.Errorf("a requirement no tree holds: %v, want an error naming it", err) + } +} + +// Issue 1921 (the group check of c5d32074c945): the integration level has no network, so the fetch, which has, downloads +// the graph the composer test builds: a sibling that is a Go module at its root, laid with what it requires at the zero +// version, found among the fetched tree and the other siblings. A failed download fails the fetch, naming the sibling. +func TestTheFetchDownloadsTheGraphOfASiblingModuleLaidWithTheTreesItRequires(t *testing.T) { + root := t.TempDir() + put := func(dir, body string) string { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return dir + } + tree := filepath.Join(root, "tree") + put(filepath.Join(tree, "modules", "x"), "module git.novox.be/novox/mesh-catalog/modules/x\n\ngo 1.26.0\n") + host := put(filepath.Join(root, "beside", "mesh-host"), "module github.com/novox/mesh-host\n\ngo 1.26.0\n\nrequire (\n\tgit.novox.be/novox/mesh-catalog/modules/x "+zeroVersion+"\n\tgit.novox.be/novox/mesh-sdk/go "+zeroVersion+"\n)\n") + put(filepath.Join(root, "beside", "mesh-sdk", "go"), "module git.novox.be/novox/mesh-sdk/go\n\ngo 1.26.0\n") + var said []string + var lines, envs []string + fail := false + m := &Module{Say: func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) }} + m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { + lines = append(lines, name+" "+strings.Join(args, " ")) + envs = append(envs, strings.Join(env, "\n")) + if fail { + return forgekey.Ran{Status: 1, Stderr: "dial tcp: network is unreachable"} + } + return forgekey.Ran{} + } + if err := m.downloadBeside(context.Background(), root, tree, []string{"modules/x"}); err != nil { + t.Fatal(err) + } + if len(lines) != 1 || lines[0] != "go -C "+host+" mod download" || !strings.Contains(envs[0], "GOWORK="+filepath.Join(root, "beside-0.work")) { + t.Fatalf("commands %q env %q, want one go mod download of the node-engine's module over its workspace", lines, envs) + } + work, _ := os.ReadFile(filepath.Join(root, "beside-0.work")) + for _, want := range []string{filepath.Join(tree, "modules", "x"), filepath.Join(root, "beside", "mesh-sdk", "go"), host} { + if !strings.Contains(string(work), want) { + t.Errorf("the workspace lays no %s:\n%s", want, work) + } + } + fail = true + if err := m.downloadBeside(context.Background(), root, tree, []string{"modules/x"}); err == nil || !strings.Contains(err.Error(), "mesh-host") || !strings.Contains(err.Error(), "network is unreachable") { + t.Errorf("a failed download = %v, want it named", err) + } + // A requirement no tree holds is said and the sibling left out; the test that builds it fails naming the module. + put(filepath.Join(root, "beside", "mesh-host"), "module github.com/novox/mesh-host\n\ngo 1.26.0\n\nrequire git.novox.be/novox/nobody/go "+zeroVersion+"\n") + lines, said = nil, nil + if err := m.downloadBeside(context.Background(), root, tree, nil); err != nil || len(lines) != 0 || len(said) != 1 || !strings.Contains(said[0], "nobody/go") { + t.Errorf("an unheld requirement: err %v, commands %q, said %q", err, lines, said) + } +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/xz_test.go b/modules/mesh-build-go/cmd/mesh-build-go/xz_test.go index d2c6ef39b..35ca8107b 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/xz_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/xz_test.go @@ -9,7 +9,7 @@ import ( "path/filepath" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // releaseXzSHA256 is the sha256 of testdata/upstream-release.tar.xz: the tar stream of upstream-release.tar.gz, @@ -45,7 +45,7 @@ func pinnedModuleServing(t *testing.T, fixture, sha, extra string) (*Module, *ke func TestAPinnedXzArchiveIsUnpackedAsTheBuildAgentDoes(t *testing.T) { m, states := pinnedModuleServing(t, "upstream-release.tar.xz", releaseXzSHA256, `,"within":"release/app"`) res, st := runBuild(t, m, states, "files") - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("the step ended %s: %q", st.State, st.Reason) } sameAsBuildAgent(t, res, buildAgentWithinStream, buildAgentWithinDigest) diff --git a/modules/mesh-build-go/files/registry b/modules/mesh-build-go/files/registry deleted file mode 100644 index 1bcadab26..000000000 --- a/modules/mesh-build-go/files/registry +++ /dev/null @@ -1 +0,0 @@ -${seat:mesh-artifact-store:reach} diff --git a/modules/mesh-build-go/go.mod b/modules/mesh-build-go/go.mod index 2b01790e0..13f373b82 100644 --- a/modules/mesh-build-go/go.mod +++ b/modules/mesh-build-go/go.mod @@ -3,16 +3,27 @@ module meshbuildgo go 1.26.0 require ( - git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0 - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildgo v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 github.com/distribution/distribution/v3 v3.1.2 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 github.com/therootcompany/xz v1.0.1 + golang.org/x/mod v0.42.0 ) require ( - github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect + github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -31,10 +42,10 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hashicorp/golang-lru/arc/v2 v2.0.5 // indirect github.com/hashicorp/golang-lru/v2 v2.0.5 // indirect - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/minio/highwayhash v1.0.4 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/nats-io/jwt/v2 v2.8.1 // indirect + github.com/nats-io/jwt/v2 v2.8.2 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect @@ -76,7 +87,7 @@ require ( golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect golang.org/x/text v0.42.0 // indirect - golang.org/x/time v0.15.0 // indirect + golang.org/x/time v0.16.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect google.golang.org/grpc v1.83.2 // indirect @@ -84,3 +95,17 @@ require ( ) replace git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep => ./buildstep + +require git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys v0.0.0-00010101000000-000000000000 + +replace git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys => ../ssh-client/sshkeys + +require ( + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/clients/gopackageregistryclient v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/clients/npmpackageregistryclient v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/clients/ociartifactregistryclient v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/seats/gopackageregistry v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/seats/npmpackageregistry v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000 // indirect +) diff --git a/modules/mesh-build-go/go.sum b/modules/mesh-build-go/go.sum index 52c1ca63b..0087787ac 100644 --- a/modules/mesh-build-go/go.sum +++ b/modules/mesh-build-go/go.sum @@ -1,9 +1,7 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w= @@ -54,16 +52,16 @@ github.com/hashicorp/golang-lru/arc/v2 v2.0.5 h1:l2zaLDubNhW4XO3LnliVj0GXO3+/CGN github.com/hashicorp/golang-lru/arc/v2 v2.0.5/go.mod h1:ny6zBSQZi2JxIeYcv7kt2sH2PXJtirBN7RDhRpxPkxU= github.com/hashicorp/golang-lru/v2 v2.0.5 h1:wW7h1TG88eUIJ2i69gaE3uNVtEPIagzhGvHgwfx2Vm4= github.com/hashicorp/golang-lru/v2 v2.0.5/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= -github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc= +github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc= github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= @@ -157,6 +155,8 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= +golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= @@ -166,8 +166,10 @@ golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= +golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= +golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc= diff --git a/modules/mesh-build-go/module.json b/modules/mesh-build-go/module.json index 3e30a5cb1..21698ddb5 100644 --- a/modules/mesh-build-go/module.json +++ b/modules/mesh-build-go/module.json @@ -1,6 +1,10 @@ { "module": "mesh-build-go", "version": "1", + "packages": { + "builds": "0.1", + "sshkeys": "0.1" + }, "moved": true, "runs-as": "mesh-build-go", "capabilities": [ @@ -10,7 +14,7 @@ "state": [ "toolchains", "keys", - {"name": "steps", "ttl-seconds": 172800}, "pauses" + {"name": "steps", "ttl-seconds": 172800}, "pauses", "findings" ], "claims": [ { @@ -19,11 +23,18 @@ "serves": ["current", "pause", "resume"] } ], + "consumes": [ + "mesh-delivery-builder.version.numbered" + ], "reads": [ - "mesh-delivery-checker.kept", - "mesh-delivery-indexer.manifests" + "mesh-delivery-checker.kept" ], "invokes": [ + "mesh-delivery-indexer.indexer_read_manifests", + "seat:oci-artifact-registry.publish", + "seat:oci-artifact-registry.publish-part", + "seat:go-package-registry.publish", + "seat:go-package-registry.publish-part", "seat:mesh-delivery-checker.input" ], "config": { @@ -71,9 +82,12 @@ "requires": [ "forge-account", "go-toolchain", - "artifact-store", + "go-package-registry", "node-tools" ], + "binds": { + "go-package-registry": "${dir:state}/go-package-registry.json" + }, "own-secrets": { "broker": "${dir:state}/broker" }, @@ -161,14 +175,6 @@ "mode": "0644", "template": "files/gitconfig" }, - { - "id": "registry", - "type": "file", - "path": "${dir:state}/registry", - "owner": "mesh-build-go", - "mode": "0644", - "template": "files/registry" - }, { "id": "git", "type": "package", @@ -207,6 +213,15 @@ "name": "files", "kind": "archive", "from": "files" + }, + { + "name": "buildstep", + "kind": "package", + "language": "go", + "paths": [ + "buildstep" + ], + "series": "0.1" } ] } diff --git a/modules/mesh-build-image/.gitignore b/modules/mesh-build-image/.gitignore index 00eb03a80..6a944359a 100644 --- a/modules/mesh-build-image/.gitignore +++ b/modules/mesh-build-image/.gitignore @@ -1 +1,3 @@ -mesh-build-image +# the binary, built at the module or in its command directory; never the command directory itself +/mesh-build-image +/cmd/mesh-build-image/mesh-build-image diff --git a/modules/mesh-build-image/README.md b/modules/mesh-build-image/README.md index 86ebe7431..e0f7dd925 100644 --- a/modules/mesh-build-image/README.md +++ b/modules/mesh-build-image/README.md @@ -34,14 +34,23 @@ The Dockerfile's steps may use the network (ADR 0487 §6); nothing a step can re - `build_image_build` builds one image artifact: `buildah bud` with the Dockerfile `from` names (in the module's directory or a subdirectory of it) as file and the module's own directory as context, so a COPY names paths from the module's root (issue 1703), `--target`, and the images `build.on` pins as `--build-arg`. Answers the image's digest. With - `publish: true` (a merged trunk commit only) the module's own process then pushes it to the registry under - `/:` (the first 12 hex digits of the digest unless the call gives one), waiting out - a registry held still, and the key carries the digest the registry serves and the reference pinned by it. Without - `publish` (a pull request) nothing is pushed. -- `build_image_copy` copies an upstream artifact (`module`, `artifact`, `from`) registry to registry into the artifact store - over the registry API, the whole index and every blob, by the digest the manifest pins (ADR 0498 §4), and verifies the - store serves that digest. Answers the digest and the reference `//@sha256:`. A - `from` without a digest fails `not pinned:`. It needs no workspace and no buildah. + `publish: true` (a merged trunk commit only) the module's own process then writes it as an OCI layout in the + workspace and publishes it through the seat `container-image-registry` (`publish`, `publish-part`, which the manifest + `invokes`; design 51 *Publishing*) as `/` at the ask's `version` (and a `tag` only when the call gives + one): its config, layers and manifest by digest, of which only the parts the holder lacks travel. The key carries the + layout's manifest digest and the seat's reference `container-image-registry//@sha256:`; a + publish the holder does not take fails `not stored:`. Without `publish` (a pull request) nothing is published. + An image the builder had not numbered yet is published by its digest alone; the module consumes the builder's + `version.numbered` and publishes that held digest under its version, sending nothing (`latepublish.go`, ADR 0617 §1); + a holder that no longer holds the digest fails the event, which is offered again. +- `build_image_copy` copies an upstream artifact (`module`, `artifact`, `from`) into the image seat by its `mirror`, by the + digest the manifest pins (ADR 0498 §4): the holder copies it and says `published` or `refused` on the seat's events, + which the manifest `consumes`; the step ends on that outcome (or fails `not stored:` after 30 minutes without one) and + answers the digest and the seat's reference. A `from` without a digest fails `not pinned:` before the seat is asked. It + needs no workspace, no buildah, and reads no outside registry itself. The copy's wait is kept in the state + `mirror-waits` under its digest before the seat is asked: the seat's events reach one instance of the module, which + writes the outcome into that key, and the waiting copy reads it there, woken by its watch of the state. A copy removes + its wait when it ends; one a stopped process left goes after two days. `build_image_mirror_waits` reads that state. - It refuses, naming why, an image whose build context is a whole repository (`context`) or whose `build.on` names another module's artifact (`unsupportedImage` in `build.go`): those forms belong to the images build-agent builds until it is removed at R4g. @@ -53,4 +62,5 @@ The Dockerfile's steps may use the network (ADR 0487 §6); nothing a step can re (issue 1839). A copy writes nothing to the disk and is not refused. - `build_image_step`, `build_image_steps`, `build_image_status` and `build_image_health` read. -The registry's address is placed into `/registry` as `${seat:mesh-artifact-store:reach}` (host:port): the provisioner's client fills a moved module's placement, and the artifact store has not moved, so a `${bound:artifact-store:...}` is refused at composition (issue 1703). +The module holds no registry address and no registry credential: every write goes through the image seat's verbs, and +the holder checks each publish with the builder. diff --git a/modules/mesh-build-image/cmd/mesh-build-image/announce.go b/modules/mesh-build-image/cmd/mesh-build-image/announce.go index ee48ed0a0..0ffa6fff6 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/announce.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/announce.go @@ -14,6 +14,7 @@ var Kept = []announce.State{ {Name: "keys", Readable: true}, {Name: "steps", Readable: true}, {Name: "pauses", Readable: true}, + {Name: MirrorWaitsState}, } // keepStates announces Kept as this module's (stdio.Keeps): main calls it before it serves. diff --git a/modules/mesh-build-image/cmd/mesh-build-image/asks.go b/modules/mesh-build-image/cmd/mesh-build-image/asks.go index 75c58dae0..d4f3711dd 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/asks.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/asks.go @@ -5,9 +5,10 @@ import ( "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildimage" + buildrun "git.novox.be/novox/mesh-delivery/sdk/buildrun" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" stdio "git.novox.be/novox/mesh-sdk/go" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" ) // **The module pulls its seat's asks** (novox/hq ADR 0525 §2-§6; issue 1887 A3image), as mesh-build-go does: the seat @@ -36,7 +37,7 @@ func (m *Module) askSteps(steps []StepTool) buildstep.AskSteps { // Puller is the module's pull of its seat's asks, on source (the runtime's seat pull when nil). func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep.Puller { if source == nil { - source = buildstep.SeatAsks{Language: delivery.AskImage} + source = buildstep.SeatAsks{Language: sdkstep.AskImage} } capacity := m.Capacity // The capacity's own change wakes the puller (issue 1887): the runtime's watch of the config value as mesh-config @@ -54,16 +55,16 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep capacity = buildstep.NodeCapacity(m.Settings, m.Node, m.Say) settings = m.Settings watch = buildstep.WatchConfigServed - cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[delivery.AskImage], m.Say) + cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[sdkstep.AskImage], m.Say) } ask := m.askSteps(steps) var load func(string) ([]byte, error) if m.ReadPauses.Get != nil { load = func(key string) ([]byte, error) { return m.ReadPauses.Get(key) } } - return &buildstep.Puller{Source: source, Language: delivery.AskImage, Node: m.Node, Put: m.Steps, PausePut: m.Pauses, Load: load, + return &buildstep.Puller{Source: source, Language: sdkstep.AskImage, Node: m.Node, Put: m.Steps, PausePut: m.Pauses, Load: load, Capacity: capacity, SettingsFile: settings, WatchConfig: watch, CoresPerStep: cores, BuildHours: hours, Eligible: m.Eligible, Logf: m.Say, - Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { + Run: func(ctx context.Context, a sdkstep.StepAsk) (any, error) { if err := m.Check(ctx); err != nil { return nil, err } @@ -83,13 +84,13 @@ func (m *Module) SeatTools(p *buildstep.Puller) []stdio.Tool { const StepEndTool = "build_image_step_end" // endLeftRunning is the module's start sweep (issue 1890): it ends the steps of its node that a previous process left -// running (sdkstep.EndStaleAtStart; every step names its owner). main runs it once at the start, and the module's test +// running (buildrun.EndStaleAtStart; every step names its owner). main runs it once at the start, and the module's test // runs it through the conformance kit's left-running case. -func (m *Module) endLeftRunning(ctx context.Context, scan sdkstep.Scan, booted time.Time, logf func(string, ...any)) { - sdkstep.EndStaleAtStart(ctx, scan, m.Node, booted, logf) +func (m *Module) endLeftRunning(ctx context.Context, scan buildrun.Scan, booted time.Time, logf func(string, ...any)) { + buildrun.EndStaleAtStart(ctx, scan, m.Node, booted, logf) } // stepScan is the module's steps state as buildstep reads it. -func (m *Module) stepScan() sdkstep.Scan { - return sdkstep.ScanOf(stdio.State(delivery.StepsState)) +func (m *Module) stepScan() buildrun.Scan { + return buildrun.ScanOf(stdio.State(seat.StateSteps)) } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/asks_test.go b/modules/mesh-build-image/cmd/mesh-build-image/asks_test.go index 565f40ac9..4ff1a2d4a 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/asks_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/asks_test.go @@ -7,8 +7,7 @@ import ( "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) const askCommit = "0123456789abcdef0123456789abcdef01234567" @@ -32,23 +31,23 @@ func TestAPulledImageAskCopiesThenBuilds(t *testing.T) { answer("build_image_copy", map[string]any{"digest": "sha256:dd", "reference": "registry/m/base@sha256:dd"}), } src := buildstep.NewFakeAsks() - a := delivery.StepAsk{ID: sdkstep.NewID(), Kind: delivery.AskDelivery, Language: delivery.AskImage, Asker: "mesh-delivery-builder", - At: time.Now(), Build: &delivery.BuildJob{Module: "m", Path: "modules/m", Publish: true, - Fetch: map[string]any{"repository": "novox/mesh-catalog", "commit": askCommit}, - Artifacts: []delivery.AskArtifact{{Artifact: "base", From: "docker.io/library/alpine@sha256:00"}, {Artifact: "server"}}}} + a := sdkstep.StepAsk{ID: sdkstep.NewID(), Kind: sdkstep.AskDelivery, Language: sdkstep.AskImage, Asker: "mesh-delivery-builder", + At: time.Now(), Build: &sdkstep.BuildJob{Module: "m", Path: "modules/m", Publish: true, + Fetch: &sdkstep.Fetch{Repository: "novox/mesh-catalog", Commit: askCommit}, + Artifacts: []sdkstep.AskArtifact{{Artifact: "base", From: "docker.io/library/alpine@sha256:00"}, {Artifact: "server"}}}} _ = src.PublishAsk(a) p := m.Puller(steps, src) p.PullWait = 10 * time.Millisecond ctx, stop := context.WithCancel(context.Background()) defer stop() go p.Pull(ctx) - key, _ := delivery.AskStepKey("ace", a.ID) - var step delivery.Step + key, _ := sdkstep.AskStepKey("ace", a.ID) + var step sdkstep.Step for deadline := time.Now().Add(3 * time.Second); ; time.Sleep(5 * time.Millisecond) { states.mu.Lock() v := states.steps[key] states.mu.Unlock() - if len(v) > 0 && v[len(v)-1].State != delivery.StepRunning { + if len(v) > 0 && v[len(v)-1].State != sdkstep.StepRunning { step = v[len(v)-1] break } @@ -56,7 +55,7 @@ func TestAPulledImageAskCopiesThenBuilds(t *testing.T) { t.Fatalf("the ask's step never ended: calls %v", calls) } } - if step.State != delivery.StepPassed || !strings.Contains(string(step.Result), "registry/m/server@sha256:cc") || + if step.State != sdkstep.StepPassed || !strings.Contains(string(step.Result), "registry/m/server@sha256:cc") || !strings.Contains(string(step.Result), "registry/m/base@sha256:dd") { t.Fatalf("the step = %+v", step) } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/build.go b/modules/mesh-build-image/cmd/mesh-build-image/build.go index fba7ceae7..307446b48 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/build.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/build.go @@ -3,8 +3,8 @@ package main // The build step (novox/hq ADR 0487 §1 §2 §5 §6; issue 1703c): one `image` artifact of a module of a fetched workspace, // built from the Dockerfile in the module's own directory with buildah, rootless: as the module's own account, in a // user namespace, with its image store outside the home, no mount of the host, no registry credential and an -// environment built from scratch. The step runs the Dockerfile and nothing else; the push of a trunk build is made -// after the build has ended (push.go). +// environment built from scratch. The step runs the Dockerfile and nothing else; the publish of a trunk build through +// the image seat is made after the build has ended (publish.go). import ( "context" @@ -21,7 +21,7 @@ import ( ) // BuildResult is what a passed build step says: the image's manifest digest as buildah holds it, and, for a trunk -// build that published, the digest the registry serves and the reference pinned by it. +// build that published, the digest it was published by and the seat's reference pinned by it. type BuildResult struct { Artifact string `json:"artifact"` Digest string `json:"digest"` @@ -36,16 +36,19 @@ func (m *Module) buildTool() StepTool { Name: BuildTool, Description: "Build one image artifact of a module of a fetched workspace from the Dockerfile in the module's own directory, " + "rootless with buildah. Answers a step id at once; the step ends on its key with the image's digest. With publish " + - "true (a merged trunk commit) the image is then pushed to the registry from this module's own process and the key " + - "carries the digest the registry serves and the reference pinned by it; without it (a pull request) nothing is pushed.", + "true (a merged trunk commit) the image is then published through the seat container-image-registry from this " + + "module's own process, only the layers the holder lacks, and the key carries the digest it was published by and the " + + "seat's reference, container-image-registry//@sha256:; without it (a pull request) nothing is published.", Input: map[string]any{ "type": "object", "properties": map[string]any{ "workspace": map[string]any{"type": "string", "description": "the workspace id build_image_fetch answered"}, "module": map[string]any{"type": "string", "description": "the module's directory in the workspace, such as modules/n8n"}, "artifact": map[string]any{"type": "string", "description": "the image artifact's name in the module's manifest"}, - "publish": map[string]any{"type": "boolean", "description": "push the image to the registry after the build: only for a merged trunk commit"}, - "tag": map[string]any{"type": "string", "description": "the registry tag of a published image; the first 12 hex digits of the digest when left out"}, + "publish": map[string]any{"type": "boolean", "description": "publish the image through the image seat after the build: only for a merged trunk commit"}, + "seat": map[string]any{"type": "string", "description": "the seat it is published through, container-image-registry (the ask's own)"}, + "version": map[string]any{"type": "string", "description": "the version the builder gave it, named in the seat's publish"}, + "tag": map[string]any{"type": "string", "description": "a tag the published version carries, by hand; none when left out"}, }, "required": []string{"workspace", "module", "artifact"}, }, @@ -111,6 +114,10 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) { name, _ := args["artifact"].(string) publish, _ := args["publish"].(bool) tag, _ := args["tag"].(string) + version, _ := args["version"].(string) + if seat, _ := args["seat"].(string); seat != "" && seat != buildstep.ImageSeat { + return nil, &buildstep.Refusal{Text: fmt.Sprintf("an image is published through %s, not %s", buildstep.ImageSeat, seat)} + } if !workspaceOK.MatchString(workspace) { return nil, fmt.Errorf("%q is not a workspace id", workspace) } @@ -177,10 +184,7 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) { if !publish { return res, nil } - if tag == "" { - tag = strings.TrimPrefix(digest, "sha256:")[:12] - } - return m.push(ctx, workspace, manifest.Module, local, res, tag) + return m.publish(ctx, workspace, manifest.Module, local, res, version, tag) } // dockerfileOf is the Dockerfile an artifact's `from` names and the build context: the module's own directory, the diff --git a/modules/mesh-build-image/cmd/mesh-build-image/build_test.go b/modules/mesh-build-image/cmd/mesh-build-image/build_test.go index fddcc86dd..8430c34a9 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/build_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/build_test.go @@ -2,14 +2,14 @@ package main import ( "encoding/json" - "git.novox.be/novox/mesh-sdk/go/forgekey" "os" "path/filepath" "strings" "testing" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/forgekey" ) // The fixture module (testdata/repos/image): a Dockerfile that adds a file to an upstream base the manifest pins in @@ -38,7 +38,7 @@ func place(t *testing.T, m *Module, edit func(string) string) { func same(s string) string { return s } -func runBuild(t *testing.T, m *Module, states *keptStates, extra map[string]any) (BuildResult, delivery.Step) { +func runBuild(t *testing.T, m *Module, states *keptStates, extra map[string]any) (BuildResult, sdkstep.Step) { t.Helper() args := map[string]any{"workspace": "ws1", "module": "modules/demo", "artifact": "server"} for k, v := range extra { @@ -49,7 +49,7 @@ func runBuild(t *testing.T, m *Module, states *keptStates, extra map[string]any) } st := states.waitEnd(t) var res BuildResult - if st.State == delivery.StepPassed { + if st.State == sdkstep.StepPassed { if err := json.Unmarshal(st.Result, &res); err != nil { t.Fatal(err) } @@ -73,7 +73,7 @@ func TestBuildRunsBuildahRootlessAndAnswersTheDigest(t *testing.T) { t.Setenv("MESH_BUILD_CPU_SHARE", "50") // the composer's share (ADR 0553): buildah is given its threads m, f, states := setup(t, same) res, st := runBuild(t, m, states, nil) - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("step = %+v", st) } if res.Digest != imageDigest || res.Artifact != "server" || res.Pushed || res.Reference != "" { @@ -144,7 +144,7 @@ func TestBuildContextIsTheModulesDirectoryForADockerfileInASubdirectory(t *testi } } _, st := runBuild(t, m, states, nil) - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("step = %+v", st) } bud := f.called("bud") @@ -183,7 +183,7 @@ func TestBuildFailsWithBuildahsError(t *testing.T) { m, f, states := setup(t, same) f.failBud = c.ran _, st := runBuild(t, m, states, nil) - if st.State != delivery.StepFailed { + if st.State != sdkstep.StepFailed { t.Errorf("%s: step = %+v, want failed", name, st) continue } @@ -223,7 +223,7 @@ func TestBuildRefusesAnImageWithARepositoryContextOrAModuleBase(t *testing.T) { for name, c := range cases { m, f, states := setup(t, c.edit) _, st := runBuild(t, m, states, nil) - if st.State != delivery.StepRefused { + if st.State != sdkstep.StepRefused { t.Errorf("%s: step = %+v, want refused", name, st) continue } @@ -266,7 +266,7 @@ func TestBuildFailsForWhatIsNotAnImageOrNotThere(t *testing.T) { } { m, f, states := setup(t, tc.edit) _, st := runBuild(t, m, states, tc.args) - if st.State != delivery.StepFailed { + if st.State != sdkstep.StepFailed { t.Errorf("%s: step = %+v, want failed", name, st) } if len(f.called("bud")) != 0 { @@ -317,7 +317,7 @@ func TestAManifestFallingBackToAPredefinedKeyBuilds(t *testing.T) { return string(out) }) res, st := runBuild(t, m, states, nil) - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("step = %+v", st) } if res.Digest != imageDigest || res.Artifact != "server" { diff --git a/modules/mesh-build-image/cmd/mesh-build-image/bus_integration_test.go b/modules/mesh-build-image/cmd/mesh-build-image/bus_integration_test.go index bc99e9836..f5167f7c3 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/bus_integration_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/bus_integration_test.go @@ -16,7 +16,7 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" @@ -100,23 +100,23 @@ func call(t *testing.T, nc *nats.Conn, tool string, args map[string]any) map[str } // ended waits for the step's key in the bucket to leave running and answers the step as the bucket holds it. -func ended(t *testing.T, kv jetstream.KeyValue, key string) delivery.Step { +func ended(t *testing.T, kv jetstream.KeyValue, key string) sdkstep.Step { t.Helper() deadline := time.Now().Add(30 * time.Second) for time.Now().Before(deadline) { if e, err := kv.Get(context.Background(), key); err == nil { - st, err := delivery.DecodeStep(e.Value()) + st, err := sdkstep.DecodeStep(e.Value()) if err != nil { t.Fatal(err) } - if st.State != delivery.StepRunning { + if st.State != sdkstep.StepRunning { return st } } time.Sleep(20 * time.Millisecond) } t.Fatalf("the key %s never left running", key) - return delivery.Step{} + return sdkstep.Step{} } func TestTheStepsAnswerThroughARealBusWithTheCapturedShapes(t *testing.T) { @@ -128,7 +128,7 @@ func TestTheStepsAnswerThroughARealBusWithTheCapturedShapes(t *testing.T) { // The fetch: a running key at once, then the workspace on the key. started := call(t, nc, FetchTool, map[string]any{"repository": "novox/mesh-catalog", "commit": commitSHA}) - if started["state"] != delivery.StepRunning || started["key"] == "" { + if started["state"] != sdkstep.StepRunning || started["key"] == "" { t.Fatalf("the fetch answered %v, want a running step and its key", started) } st := ended(t, kv, started["key"].(string)) @@ -142,7 +142,7 @@ func TestTheStepsAnswerThroughARealBusWithTheCapturedShapes(t *testing.T) { } started := call(t, nc, BuildTool, args) st := ended(t, kv, started["key"].(string)) - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("%s: step = %+v", name, st) } var res BuildResult @@ -151,7 +151,7 @@ func TestTheStepsAnswerThroughARealBusWithTheCapturedShapes(t *testing.T) { } compareCaptured(t, name, normalized(t, st, "", "", f.address())) } - if len(f.registry) != 1 || f.pushes != 1 { - t.Errorf("the registry holds %v after %d pushes, want only the trunk build's one", f.registry, f.pushes) + if len(f.published) != 1 || f.pushes != 1 { + t.Errorf("the image seat was asked %v after %d layouts, want only the trunk build's one", f.published, f.pushes) } } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/capture_test.go b/modules/mesh-build-image/cmd/mesh-build-image/capture_test.go index f251ca7e8..ddbc1389f 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/capture_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/capture_test.go @@ -17,7 +17,7 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) const capturedDir = "testdata/captured" @@ -25,7 +25,7 @@ const capturedDir = "testdata/captured" var hexDigest = regexp.MustCompile(`sha256:[0-9a-f]{64}`) // normalized is a stored step with the words that differ between runs put to fixed ones. -func normalized(t *testing.T, st delivery.Step, workspace, commit, registry string) []byte { +func normalized(t *testing.T, st sdkstep.Step, workspace, commit, registry string) []byte { t.Helper() raw, err := json.Marshal(st) if err != nil { @@ -75,7 +75,7 @@ func compareCaptured(t *testing.T, name string, got []byte) { } } -func workspaceOf(t *testing.T, st delivery.Step) string { +func workspaceOf(t *testing.T, st sdkstep.Step) string { var r struct { Workspace string `json:"workspace"` } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/compose_test.go b/modules/mesh-build-image/cmd/mesh-build-image/compose_test.go index 7acb44549..b24aa8138 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/compose_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/compose_test.go @@ -1,25 +1,45 @@ package main import ( + "encoding/json" "os" "path/filepath" + "slices" "strings" "testing" ) -// Issue 1703 (the live failure of the first assignment): the module is moved, so the provisioner's client on the node -// fills its templates, and it fills only config, secrets, collections, placement and what a moved provider told it. The -// registry's address therefore cannot be a ${bound:artifact-store:...} (the artifact store has not moved): it is the -// placement ${seat:mesh-artifact-store:reach}, the form the container runtime's daemon.json uses. The module's own part -// of that is what this holds: its registry file is that placement and none of its files or its manifest binds the store. -// That a node composes it is the node-engine's composition test (.work/sdk1/proofs-for-node-engine.md). -func TestTheRegistryIsThePlacementAndNoFileBindsTheStore(t *testing.T) { - raw, err := os.ReadFile(filepath.Join(moduleDir, "files", "registry")) - if err != nil { +// CAT-BI1 (design 51 *Publishing*): the module publishes and copies through the image seat's verbs, which its manifest +// invokes, hears the seat's outcomes through its consumes, and holds no registry: no registry file, no artifact-store +// requirement, and none of its files names the store's assignment or binds it (issue 1703: the module is moved, and only +// what the mesh fills reaches its files). +func TestTheModuleInvokesTheImageSeatAndHoldsNoRegistry(t *testing.T) { + var m struct { + Requires []string `json:"requires"` + Invokes []string `json:"invokes"` + Consumes []string `json:"consumes"` + } + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil || json.Unmarshal(raw, &m) != nil { t.Fatal(err) } - if got := strings.TrimSpace(string(raw)); got != "${seat:mesh-artifact-store:reach}" { - t.Errorf("the registry file is %q, want the placement ${seat:mesh-artifact-store:reach}", got) + if want := []string{"seat:container-image-registry.publish", "seat:container-image-registry.publish-part", + "seat:container-image-registry.mirror"}; !slices.Equal(m.Invokes, want) { + t.Errorf("invokes = %v, want %v", m.Invokes, want) + } + // The builder's version.numbered for the late publish (ADR 0617 §1), and the seat's outcomes a copy waits on. + consumes := []string{NumberedPattern} + for _, e := range OutcomeEvents { + consumes = append(consumes, "container-image-registry."+e) + } + if !slices.Equal(m.Consumes, consumes) { + t.Errorf("consumes = %v, want %v", m.Consumes, consumes) + } + if slices.Contains(m.Requires, "artifact-store") { + t.Errorf("requires = %v: the module holds no store", m.Requires) + } + if _, err := os.Stat(filepath.Join(moduleDir, "files", "registry")); !os.IsNotExist(err) { + t.Errorf("files/registry is still there: %v", err) } files := []string{filepath.Join(moduleDir, "module.json")} entries, err := os.ReadDir(filepath.Join(moduleDir, "files")) @@ -34,8 +54,8 @@ func TestTheRegistryIsThePlacementAndNoFileBindsTheStore(t *testing.T) { if err != nil { t.Fatal(err) } - if strings.Contains(string(body), "${bound:artifact-store") { - t.Errorf("%s binds the artifact store, which has not moved: a moved module's client cannot fill it", f) + if strings.Contains(string(body), "${bound:artifact-store") || strings.Contains(string(body), "mesh-artifact-store") { + t.Errorf("%s names the artifact store", f) } } } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/copy.go b/modules/mesh-build-image/cmd/mesh-build-image/copy.go index 7a7405f5b..28655df7e 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/copy.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/copy.go @@ -1,34 +1,42 @@ package main -// The copy step (novox/hq ADR 0498 §4; issue 1587): an upstream artifact is copied registry to registry into the -// artifact store, whole (an index with every manifest it lists, every blob), by the digest its manifest pins. The copy -// runs no code of a change, so it needs neither buildah nor an isolation; it is made over the registry API, which -// keeps every digest as it is (a pull and a push through buildah may rewrite a manifest). An upstream without a -// digest is not copied: `not pinned:`. A copy that does not end with the pinned digest in the store is `not stored:`. +// The copy step (novox/hq ADR 0498 §4; design 51: an upstream image is copied by `mirror` with its pinned digest; +// CAT-BI1): an upstream artifact is copied into the image seat by the seat's verb mirror, which the manifest +// `invokes`, by the digest its manifest pins. The holder copies it, every platform and only what it lacks, and says +// published or refused on the seat's events; the step waits on that outcome, heard through the manifest's `consumes`, +// not on the call, so a large base never outlasts a call's bound; the wait is kept in the state mirror-waits, so an +// outcome another instance heard is matched there. The copy runs no code of a change and reads no +// outside registry itself. An upstream without a digest is not copied: `not pinned:`. A copy the holder does not end +// with the pinned digest stored is `not stored:`. import ( - "bytes" "context" - "crypto/sha256" - "encoding/hex" "encoding/json" + "errors" "fmt" - "io" - "net/http" - "net/url" "regexp" + "sort" + "strconv" "strings" + "sync" "time" + reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" + builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // CopyTool is the copy step's tool. const CopyTool = "build_image_copy" -// CopyResult is what a passed copy step says: the pinned digest, now in the store, and the reference to it in the -// form an image's is (//@sha256:). +// MirrorWait is how long a copy waits for the holder's outcome before it fails `not stored:`. +const MirrorWait = 30 * time.Minute + +// CopyResult is what a passed copy step says: the pinned digest, now held by the image seat, and the seat's reference +// to it (container-image-registry/@sha256:). type CopyResult struct { Artifact string `json:"artifact"` Digest string `json:"digest"` @@ -37,19 +45,17 @@ type CopyResult struct { } var ( - nameOK = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$`) - pinnedOK = regexp.MustCompile(`@(sha256:[0-9a-f]{64})$`) - manifestKind = strings.Join([]string{ - "application/vnd.oci.image.index.v1+json", "application/vnd.docker.distribution.manifest.list.v2+json", - "application/vnd.oci.image.manifest.v1+json", "application/vnd.docker.distribution.manifest.v2+json"}, ", ") + nameOK = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$`) + pinnedOK = regexp.MustCompile(`@(sha256:[0-9a-f]{64})$`) ) func (m *Module) copyTool() StepTool { return StepTool{ Name: CopyTool, - Description: "Copy an upstream image, registry to registry, into the artifact store by the digest the manifest pins " + - "(novox/hq ADR 0498 §4). Answers a step id at once; the step ends on its key with the digest in the store and the " + - "reference to it. A reference without a digest fails `not pinned:`. Needs no workspace and runs no code of a change.", + Description: "Copy an upstream image into the seat container-image-registry by the digest the manifest pins, through " + + "the seat's mirror (novox/hq ADR 0498 §4, design 51). Answers a step id at once; the step ends on its key with the " + + "digest held and the seat's reference to it, when the holder says published. A reference without a digest fails " + + "`not pinned:`, a refusal `not stored:`. Needs no workspace and runs no code of a change.", Input: map[string]any{ "type": "object", "properties": map[string]any{ @@ -64,38 +70,13 @@ func (m *Module) copyTool() StepTool { } } -// upstreamRef is an image reference split the way a container runtime does: no host means the public hub. -type upstreamRef struct { - base, repository, digest string -} - -func parseUpstream(from string) (upstreamRef, error) { +// pinned is the digest a reference pins. +func pinned(from string) (string, error) { pin := pinnedOK.FindStringSubmatch(from) - if pin == nil { - return upstreamRef{}, fmt.Errorf("%q names no digest", from) + if pin == nil || strings.TrimSuffix(from, "@"+pin[1]) == "" { + return "", fmt.Errorf("%q names no digest", from) } - name := strings.TrimSuffix(from, "@"+pin[1]) - if colon := strings.LastIndex(name, ":"); colon > strings.LastIndex(name, "/") { - name = name[:colon] // the tag is only what it was called; the digest names the image - } - if name == "" { - return upstreamRef{}, fmt.Errorf("%q is not an image reference", from) - } - host, repository := "docker.io", name - if slash := strings.Index(name, "/"); slash >= 0 && (strings.ContainsAny(name[:slash], ".:") || name[:slash] == "localhost") { - host, repository = name[:slash], name[slash+1:] - } - if host == "docker.io" { - host = "registry-1.docker.io" - if !strings.Contains(repository, "/") { - repository = "library/" + repository - } - } - scheme := "https://" - if strings.HasPrefix(host, "localhost") || strings.HasPrefix(host, "127.") { - scheme = "http://" - } - return upstreamRef{base: scheme + host, repository: repository, digest: pin[1]}, nil + return pin[1], nil } func (m *Module) copy(ctx context.Context, args map[string]any) (any, error) { @@ -105,260 +86,289 @@ func (m *Module) copy(ctx context.Context, args map[string]any) (any, error) { if !nameOK.MatchString(module) || !nameOK.MatchString(name) { return nil, fmt.Errorf("%q / %q: a module and an artifact are named in lower case letters, digits, dots, dashes and underscores", module, name) } - src, err := parseUpstream(from) + digest, err := pinned(from) if err != nil { return nil, fmt.Errorf("not pinned: %s: %v; an upstream artifact is copied by the digest its manifest pins", name, err) } - addr, err := m.registry() + if m.Outcomes == nil || m.Outcomes.Waits == nil { + return nil, fmt.Errorf("%s%s: this process hears no outcome of the image seat", builds.NotStored, name) + } + key := MirrorWaitKey(digest) + now := time.Now + if m.Now != nil { + now = m.Now + } + asked := CopyWait{Digest: digest, Module: module, Artifact: name, From: from, Node: m.Node, + Asked: now().UTC(), Token: strconv.FormatInt(now().UnixNano(), 36) + "-" + m.Node} + // Waited for before the ask, in memory and in the state mirror-waits, so an outcome said at once, or heard by another + // instance of the module (the seat's events reach one of them), is not missed. + heard, stop := m.Outcomes.Expect(digest) + defer stop() + if _, err := m.Outcomes.Waits.Put(key, asked); err != nil { + return nil, fmt.Errorf("%s%s: keeping the copy's wait in %s: %v", builds.NotStored, name, MirrorWaitsState, err) + } + defer m.Outcomes.drop(key, asked.Token) + said := buildstep.NarrateSeat(ctx, buildstep.ImageSeat, "mirror of "+from) + raw, err := m.call()("seat:"+buildstep.ImageSeat+"."+reg.VerbMirror, reg.MirrorInput{Name: module + "/" + name, Source: from, Digest: digest}) if err != nil { - return nil, fmt.Errorf("not stored: %s: %w", name, err) + said.Refused(err) + return nil, fmt.Errorf("%s%s: %s's mirror: %v", builds.NotStored, name, buildstep.ImageSeat, err) } - repository := module + "/" + name - c := &copier{m: m, src: src, dstBase: "http://" + addr, dstRepo: repository, done: map[string]bool{}} - if err := m.waitForRegistry(ctx, addr, "copying "+from, func() error { return c.copyManifest(ctx, src.digest) }); err != nil { - return nil, fmt.Errorf("not stored: %s: %w", name, err) + var answer reg.MirrorAnswer + _ = json.Unmarshal(raw, &answer) + m.say("asked %s to copy %s (%s); waiting for its outcome", buildstep.ImageSeat, from, answer.Reference) + wait := m.MirrorWait + if wait == 0 { + wait = MirrorWait } - if err := c.verify(ctx); err != nil { - return nil, fmt.Errorf("not stored: %s: %w", name, err) - } - return CopyResult{Artifact: name, Digest: src.digest, Reference: addr + "/" + repository + "@" + src.digest, Pushed: true}, nil -} - -// copier copies one image from its upstream registry into the store's repository. -type copier struct { - m *Module - src upstreamRef - dstBase string - dstRepo string - token string - done map[string]bool -} - -func (c *copier) client() *http.Client { - if c.m.HTTP != nil { - return c.m.HTTP - } - return &http.Client{Timeout: 30 * time.Minute} -} - -// get reads from the upstream registry, answering one bearer challenge with an anonymous token. -func (c *copier) get(ctx context.Context, path, accept string) (*http.Response, error) { - for attempt := 0; attempt < 2; attempt++ { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.src.base+"/v2/"+c.src.repository+path, nil) - if err != nil { - return nil, err - } - if accept != "" { - req.Header.Set("Accept", accept) - } - if c.token != "" { - req.Header.Set("Authorization", "Bearer "+c.token) - } - resp, err := c.client().Do(req) - if err != nil { - return nil, err - } - if resp.StatusCode == http.StatusUnauthorized && attempt == 0 { - challenge := resp.Header.Get("Www-Authenticate") - resp.Body.Close() - if c.token, err = c.fetchToken(ctx, challenge); err != nil { - return nil, err + bound := time.After(wait) + for { + // The state first: an outcome written there while this copy did not hear it is this copy's. + if o, ok := m.Outcomes.kept(key); ok { + res, err := copyEnd(name, o, answer.Reference) + if err != nil { + said.Refused(errors.New(o.Reason)) + } else { + said.Stored(res.(CopyResult).Reference) } + return res, err + } + select { + case <-heard: + case <-said.Waiting(): + said.StillWaiting() + case <-bound: + err := fmt.Errorf("%s said no outcome of the copy of %s in %s", buildstep.ImageSeat, from, wait) + said.Refused(err) + return nil, fmt.Errorf("%s%s: %w", builds.NotStored, name, err) + case <-ctx.Done(): + said.Refused(ctx.Err()) + return nil, fmt.Errorf("%s%s: stopped while waiting for the copy of %s: %w", builds.NotStored, name, from, ctx.Err()) + } + } +} + +// copyEnd is a copy's end on the outcome kept for it. +func copyEnd(name string, o MirrorOutcome, asked string) (any, error) { + if o.Event == reg.EventRefused { + return nil, fmt.Errorf("%s%s: %s refused the copy: %s", builds.NotStored, name, buildstep.ImageSeat, o.Reason) + } + ref := o.Reference + if ref == "" { + ref = asked + } + return CopyResult{Artifact: name, Digest: o.Digest, Reference: ref, Pushed: true}, nil +} + +// The state mirror-waits (CAT-BI1, the lead's ruling on A3r): each upstream copy waiting on the image seat's outcome, +// keyed by its digest, with the outcome once an instance of the module heard it. The seat's published and refused reach +// one instance of the module, not necessarily the one whose copy waits, and none while no instance listens; the +// instance that hears it writes it to the copy's key, and the waiting copy reads it there, woken by its watch of the +// state. A copy removes its key when it ends; one a stopped process left goes with the state's time to live. +const MirrorWaitsState = "mirror-waits" + +// MirrorWaitKey is a copy's key in mirror-waits: its digest, with the colon a key cannot hold as a dash. +func MirrorWaitKey(digest string) string { return strings.Replace(digest, ":", "-", 1) } + +// CopyWait is one value of mirror-waits: the copy asked, and its outcome once heard. +type CopyWait struct { + Digest string `json:"digest"` + Module string `json:"module"` + Artifact string `json:"artifact"` + From string `json:"from"` + Node string `json:"node"` + Asked time.Time `json:"asked"` + Token string `json:"token"` + Outcome *MirrorOutcome `json:"outcome,omitempty"` +} + +// MirrorOutcome is the image seat's published or refused of a copy, as kept. +type MirrorOutcome struct { + reg.Outcome + Event string `json:"event"` + Heard time.Time `json:"heard"` + By string `json:"by,omitempty"` +} + +// WaitState is the state mirror-waits: a key's value (nil for none), a write, a removal. +type WaitState interface { + Get(key string) (json.RawMessage, error) + Put(key string, value any) (uint64, error) + Delete(key string) error +} + +// stateWaits is mirror-waits as the process keeps it on the bus. +type stateWaits struct{ s stdio.KeptState } + +func (w stateWaits) Get(key string) (json.RawMessage, error) { + e, err := w.s.Get(key) + if err != nil || e == nil { + return nil, err + } + return e.Value, nil +} +func (w stateWaits) Put(key string, value any) (uint64, error) { return w.s.Put(key, value) } +func (w stateWaits) Delete(key string) error { return w.s.Delete(key) } + +// Outcomes hands the image seat's published and refused of an upstream copy to the copy waiting on its digest: written +// into the copy's key in Waits, and a wake for the copies of this process waiting on it. +type Outcomes struct { + // Waits is the state mirror-waits; Node names this instance in an outcome it writes; Now is the clock. + Waits WaitState + Node string + Now func() time.Time + + mu sync.Mutex + waiting map[string][]chan struct{} +} + +// Expect wakes on every change of digest's wait; stop ends it. +func (o *Outcomes) Expect(digest string) (<-chan struct{}, func()) { + ch := make(chan struct{}, 1) + o.mu.Lock() + if o.waiting == nil { + o.waiting = map[string][]chan struct{}{} + } + o.waiting[digest] = append(o.waiting[digest], ch) + o.mu.Unlock() + return ch, func() { + o.mu.Lock() + defer o.mu.Unlock() + left := o.waiting[digest][:0] + for _, c := range o.waiting[digest] { + if c != ch { + left = append(left, c) + } + } + if len(left) == 0 { + delete(o.waiting, digest) + } else { + o.waiting[digest] = left + } + } +} + +func (o *Outcomes) wake(digest string) { + o.mu.Lock() + defer o.mu.Unlock() + for _, ch := range o.waiting[digest] { + select { + case ch <- struct{}{}: + default: + } + } +} + +// read is the wait kept at key, nil for none or one this module cannot read. +func (o *Outcomes) read(key string) (*CopyWait, error) { + raw, err := o.Waits.Get(key) + if err != nil || len(raw) == 0 || string(raw) == "null" { + return nil, err + } + var w CopyWait + if json.Unmarshal(raw, &w) != nil || w.Digest == "" { + return nil, nil + } + return &w, nil +} + +// kept is the outcome kept for key's copy, when one was heard. +func (o *Outcomes) kept(key string) (MirrorOutcome, bool) { + w, err := o.read(key) + if err != nil || w == nil || w.Outcome == nil { + return MirrorOutcome{}, false + } + return *w.Outcome, true +} + +// drop removes key's wait when it is still the copy's own (token): a later copy of the same digest keeps its own. +func (o *Outcomes) drop(key, token string) { + if w, err := o.read(key); err == nil && w != nil && w.Token == token { + _ = o.Waits.Delete(key) + } +} + +// Heard takes one event of the seat (published or refused) in: an upstream copy's outcome is written into its wait in +// mirror-waits and wakes the copies of this process waiting on its digest; any other, and one no copy waits on, is +// passed. A wait the state cannot take is an error, so the event is offered again. +func (o *Outcomes) Heard(event string, body json.RawMessage) error { + var got reg.Outcome + if err := json.Unmarshal(body, &got); err != nil { + return fmt.Errorf("the image seat's %s is not an outcome: %w", event, err) + } + if got.Kind != "upstream" || got.Digest == "" || o.Waits == nil { + return nil + } + key := MirrorWaitKey(got.Digest) + w, err := o.read(key) + if err != nil { + return fmt.Errorf("reading the copy's wait %s: %w", key, err) + } + if w == nil { + return nil + } + if w.Outcome == nil { + now := time.Now + if o.Now != nil { + now = o.Now + } + w.Outcome = &MirrorOutcome{Outcome: got, Event: event, Heard: now().UTC(), By: o.Node} + if _, err := o.Waits.Put(key, w); err != nil { + return fmt.Errorf("keeping the outcome of %s in %s: %w", key, MirrorWaitsState, err) + } + } + o.wake(got.Digest) + return nil +} + +// Changed takes one change of mirror-waits in, as the module's watch of the state hands it: a wait whose outcome +// another instance wrote wakes this process's copies of its digest. +func (o *Outcomes) Changed(change stdio.StateChange) error { + var w CopyWait + if len(change.Value) == 0 || json.Unmarshal(change.Value, &w) != nil || w.Outcome == nil { + return nil + } + o.wake(w.Digest) + return nil +} + +// MirrorWaitsTool reads mirror-waits. +const MirrorWaitsTool = "build_image_mirror_waits" + +// Waiting reads every copy waiting in mirror-waits, oldest first, with its outcome once heard. +func (m *Module) Waiting() ([]CopyWait, error) { + if m.ReadWaits.Keys == nil || m.ReadWaits.Get == nil { + return nil, fmt.Errorf("the state %s cannot be read here", MirrorWaitsState) + } + keys, err := m.ReadWaits.Keys() + if err != nil { + return nil, err + } + raws, err := readValues(keys, m.ReadWaits.Get) + if err != nil { + return nil, err + } + out := []CopyWait{} + for _, raw := range raws { + var w CopyWait + if len(raw) == 0 || json.Unmarshal(raw, &w) != nil || w.Digest == "" { continue } - if resp.StatusCode != http.StatusOK { - resp.Body.Close() - return nil, fmt.Errorf("upstream answered %s for %s%s", resp.Status, c.src.repository, path) - } - return resp, nil + out = append(out, w) } - return nil, fmt.Errorf("upstream refused %s%s after a token", c.src.repository, path) + sort.Slice(out, func(i, j int) bool { return out[i].Asked.Before(out[j].Asked) }) + return out, nil } -var challengeParam = regexp.MustCompile(`(\w+)="([^"]*)"`) - -func (c *copier) fetchToken(ctx context.Context, challenge string) (string, error) { - if !strings.HasPrefix(strings.ToLower(challenge), "bearer ") { - return "", fmt.Errorf("upstream asks for credentials (%q) and this copy holds none", challenge) - } - p := map[string]string{} - for _, kv := range challengeParam.FindAllStringSubmatch(challenge, -1) { - p[kv[1]] = kv[2] - } - realm := p["realm"] - if realm == "" { - return "", fmt.Errorf("upstream's challenge %q names no realm", challenge) - } - q := url.Values{"service": {p["service"]}, "scope": {"repository:" + c.src.repository + ":pull"}} - req, err := http.NewRequestWithContext(ctx, http.MethodGet, realm+"?"+q.Encode(), nil) - if err != nil { - return "", err - } - resp, err := c.client().Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - var tok struct { - Token string `json:"token"` - Access string `json:"access_token"` - } - if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&tok) != nil || (tok.Token == "" && tok.Access == "") { - return "", fmt.Errorf("upstream's token service answered %s", resp.Status) - } - if tok.Token != "" { - return tok.Token, nil - } - return tok.Access, nil -} - -// copyManifest copies the manifest of digest and everything it names, children before the manifest itself. -func (c *copier) copyManifest(ctx context.Context, digest string) error { - if c.done[digest] { - return nil - } - resp, err := c.get(ctx, "/manifests/"+digest, manifestKind) - if err != nil { - return err - } - body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) - resp.Body.Close() - if err != nil { - return err - } - sum := sha256.Sum256(body) - if got := "sha256:" + hex.EncodeToString(sum[:]); got != digest { - return fmt.Errorf("upstream served %s for the manifest pinned as %s", got, digest) - } - var doc struct { - MediaType string `json:"mediaType"` - Config struct{ Digest string } `json:"config"` - Layers []struct{ Digest string } `json:"layers"` - Manifests []struct{ Digest string } `json:"manifests"` - } - if err := json.Unmarshal(body, &doc); err != nil { - return fmt.Errorf("the manifest %s is not JSON: %w", digest, err) - } - for _, child := range doc.Manifests { - if err := c.copyManifest(ctx, child.Digest); err != nil { - return err - } - } - blobs := []string{} - if doc.Config.Digest != "" { - blobs = append(blobs, doc.Config.Digest) - } - for _, l := range doc.Layers { - blobs = append(blobs, l.Digest) - } - for _, b := range blobs { - if err := c.copyBlob(ctx, b); err != nil { - return err - } - } - mediaType := resp.Header.Get("Content-Type") - if mediaType == "" { - mediaType = doc.MediaType - } - if err := c.putManifest(ctx, digest, mediaType, body); err != nil { - return err - } - c.done[digest] = true - return nil -} - -func (c *copier) copyBlob(ctx context.Context, digest string) error { - if !digestOK.MatchString(digest) { - return fmt.Errorf("%q is not a blob digest", digest) - } - if c.done[digest] { - return nil - } - head, err := http.NewRequestWithContext(ctx, http.MethodHead, c.dstBase+"/v2/"+c.dstRepo+"/blobs/"+digest, nil) - if err != nil { - return err - } - if r, err := c.client().Do(head); err != nil { - return err - } else { - r.Body.Close() - if r.StatusCode == http.StatusOK { - c.done[digest] = true - return nil - } - } - post, err := http.NewRequestWithContext(ctx, http.MethodPost, c.dstBase+"/v2/"+c.dstRepo+"/blobs/uploads/", nil) - if err != nil { - return err - } - pr, err := c.client().Do(post) - if err != nil { - return err - } - pr.Body.Close() - loc, err := pr.Location() - if pr.StatusCode != http.StatusAccepted || err != nil { - return fmt.Errorf("the store answered %s to a blob upload of %s", pr.Status, digest) - } - q := loc.Query() - q.Set("digest", digest) - loc.RawQuery = q.Encode() - blob, err := c.get(ctx, "/blobs/"+digest, "") - if err != nil { - return err - } - defer blob.Body.Close() - put, err := http.NewRequestWithContext(ctx, http.MethodPut, loc.String(), blob.Body) - if err != nil { - return err - } - put.ContentLength = blob.ContentLength - put.Header.Set("Content-Type", "application/octet-stream") - resp, err := c.client().Do(put) - if err != nil { - return err - } - resp.Body.Close() - if resp.StatusCode != http.StatusCreated { - return fmt.Errorf("the store answered %s to the blob %s", resp.Status, digest) - } - c.done[digest] = true - return nil -} - -func (c *copier) putManifest(ctx context.Context, digest, mediaType string, body []byte) error { - req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.dstBase+"/v2/"+c.dstRepo+"/manifests/"+digest, bytes.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("Content-Type", mediaType) - resp, err := c.client().Do(req) - if err != nil { - return err - } - resp.Body.Close() - if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { - return fmt.Errorf("the store answered %s to the manifest %s", resp.Status, digest) - } - return nil -} - -// verify reads back what the store serves for the pinned digest: only the store can say. -func (c *copier) verify(ctx context.Context) error { - req, err := http.NewRequestWithContext(ctx, http.MethodHead, c.dstBase+"/v2/"+c.dstRepo+"/manifests/"+c.src.digest, nil) - if err != nil { - return err - } - req.Header.Set("Accept", manifestKind) - resp, err := c.client().Do(req) - if err != nil { - return err - } - resp.Body.Close() - if got := resp.Header.Get("Docker-Content-Digest"); resp.StatusCode != http.StatusOK || got != c.src.digest { - return fmt.Errorf("the store answered %s with digest %q for the pinned %s", resp.Status, got, c.src.digest) - } - return nil +func (m *Module) mirrorWaitsTool() stdio.Tool { + return stdio.Tool{Name: MirrorWaitsTool, + Description: "The upstream copies waiting on the image seat's outcome of their mirror (the module's state " + MirrorWaitsState + + "), oldest first: the digest, the module and artifact, the reference, the node whose copy asked and when, and the " + + "seat's published or refused once an instance heard it. A copy removes its wait when it ends. Read-only.", + Run: func(map[string]any) (any, error) { + w, err := m.Waiting() + return map[string]any{"waits": w}, err + }} } // Plain is a long step that needs nothing of the machine: no guard. The copy runs no buildah. @@ -368,7 +378,7 @@ func (m *Module) Plain(tool string, run func(ctx context.Context, args map[strin if err != nil { return nil, err } - key, _ := delivery.StepKey(m.Node, id) - return map[string]any{"step": id, "key": key, "state": delivery.StepRunning}, nil + key, _ := sdkstep.StepKey(m.Node, id) + return map[string]any{"step": id, "key": key, "state": sdkstep.StepRunning}, nil } } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/copy_test.go b/modules/mesh-build-image/cmd/mesh-build-image/copy_test.go index d546dde0d..bd5624766 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/copy_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/copy_test.go @@ -1,124 +1,23 @@ package main import ( - "crypto/sha256" - "encoding/hex" "encoding/json" - "io" - "net/http" - "net/http/httptest" "strings" - "sync" "testing" + "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) -func digestOf(b []byte) string { s := sha256.Sum256(b); return "sha256:" + hex.EncodeToString(s[:]) } - -// memRegistry is a registry in memory: blobs and manifests by digest, per repository. -type memRegistry struct { - mu sync.Mutex - blobs map[string][]byte // repo|digest - manifests map[string][]byte // repo|digest - types map[string]string - puts []string - srv *httptest.Server -} - -func newMem(t *testing.T) *memRegistry { - r := &memRegistry{blobs: map[string][]byte{}, manifests: map[string][]byte{}, types: map[string]string{}} - r.srv = httptest.NewServer(http.HandlerFunc(r.serve)) - t.Cleanup(r.srv.Close) - return r -} - -func (r *memRegistry) addr() string { return r.srv.Listener.Addr().String() } - -func (r *memRegistry) serve(w http.ResponseWriter, req *http.Request) { - r.mu.Lock() - defer r.mu.Unlock() - path := strings.TrimPrefix(req.URL.Path, "/v2/") - if i := strings.Index(path, "/blobs/uploads/"); i >= 0 { - if req.Method == http.MethodPost { - w.Header().Set("Location", "/v2/"+path[:i]+"/blobs/uploads/u1") - w.WriteHeader(http.StatusAccepted) - return - } - body, _ := io.ReadAll(req.Body) - d := req.URL.Query().Get("digest") - if digestOf(body) != d { - w.WriteHeader(http.StatusBadRequest) - return - } - r.blobs[path[:i]+"|"+d] = body - r.puts = append(r.puts, "blob "+d) - w.WriteHeader(http.StatusCreated) - return - } - for _, kind := range []string{"blobs", "manifests"} { - if i := strings.Index(path, "/"+kind+"/"); i >= 0 { - key := path[:i] + "|" + path[i+len(kind)+2:] - store := r.blobs - if kind == "manifests" { - store = r.manifests - } - if req.Method == http.MethodPut { - body, _ := io.ReadAll(req.Body) - if digestOf(body) != path[i+len(kind)+2:] { - w.WriteHeader(http.StatusBadRequest) - return - } - r.manifests[key] = body - r.types[key] = req.Header.Get("Content-Type") - r.puts = append(r.puts, "manifest "+path[i+len(kind)+2:]) - w.WriteHeader(http.StatusCreated) - return - } - body, ok := store[key] - if !ok { - http.NotFound(w, req) - return - } - w.Header().Set("Docker-Content-Digest", path[i+len(kind)+2:]) - w.Header().Set("Content-Type", r.types[key]) - if req.Method == http.MethodHead { - return - } - w.Write(body) - return - } - } - http.NotFound(w, req) -} - -// upstreamImage puts a two-platform index (two manifests, each with a config and a layer) into r and answers the index digest. -func upstreamImage(r *memRegistry, repo string) string { - var children []map[string]string - for _, tag := range []string{"amd64", "arm64"} { - cfg, layer := []byte(`{"arch":"`+tag+`"}`), []byte("layer-"+tag) - r.blobs[repo+"|"+digestOf(cfg)], r.blobs[repo+"|"+digestOf(layer)] = cfg, layer - mf, _ := json.Marshal(map[string]any{"schemaVersion": 2, "mediaType": "application/vnd.oci.image.manifest.v1+json", - "config": map[string]string{"digest": digestOf(cfg)}, "layers": []map[string]string{{"digest": digestOf(layer)}}}) - r.manifests[repo+"|"+digestOf(mf)] = mf - r.types[repo+"|"+digestOf(mf)] = "application/vnd.oci.image.manifest.v1+json" - children = append(children, map[string]string{"digest": digestOf(mf)}) - } - idx, _ := json.Marshal(map[string]any{"schemaVersion": 2, "mediaType": "application/vnd.oci.image.index.v1+json", "manifests": children}) - d := digestOf(idx) - r.manifests[repo+"|"+d] = idx - r.types[repo+"|"+d] = "application/vnd.oci.image.index.v1+json" - return d -} - -func runCopy(t *testing.T, m *Module, states *keptStates, args map[string]any) (CopyResult, delivery.Step) { +func runCopy(t *testing.T, m *Module, states *keptStates, args map[string]any) (CopyResult, sdkstep.Step) { t.Helper() if _, err := tool(t, m, CopyTool)(args); err != nil { t.Fatal(err) } st := states.waitEnd(t) var res CopyResult - if st.State == delivery.StepPassed { + if st.State == sdkstep.StepPassed { if err := json.Unmarshal(st.Result, &res); err != nil { t.Fatal(err) } @@ -126,93 +25,73 @@ func runCopy(t *testing.T, m *Module, states *keptStates, args map[string]any) ( return res, st } -// ADR 0498 §4 / AC: the copy leaves the pinned digest in the store and answers it, with the whole index (every -// manifest it lists, every blob), and runs no buildah. -func TestTheCopyLeavesThePinnedDigestInTheStoreAndAnswersIt(t *testing.T) { - up, store := newMem(t), newMem(t) - digest := upstreamImage(up, "library/nats") - f := newFake(t) - states := newStates() - m := moduleWith(t, f, states) - m.HTTP = nil - reg := store.addr() - writeFile(t, m.RegistryFile, reg+"\n") - from := up.addr() + "/library/nats:2.11.17-alpine@" + digest - res, st := runCopy(t, m, states, map[string]any{"module": "nats", "artifact": "server", "from": from}) - if st.State != delivery.StepPassed { - t.Fatalf("step = %+v", st) - } - if res.Digest != digest || res.Reference != reg+"/nats/server@"+digest || !res.Pushed || res.Artifact != "server" { - t.Errorf("result = %+v", res) - } - if _, ok := store.manifests["nats/server|"+digest]; !ok { - t.Fatal("the index is not in the store") - } - if len(store.manifests) != 3 || len(store.blobs) != 4 { - t.Errorf("store holds %d manifests and %d blobs, want 3 and 4", len(store.manifests), len(store.blobs)) - } - if len(f.lines()) != 0 { - t.Errorf("the copy ran commands: %v", f.lines()) - } - // A second copy writes nothing new but the manifests. - store.puts = nil - if _, st := runCopy(t, m, states, map[string]any{"module": "nats", "artifact": "server", "from": from}); st.State != delivery.StepPassed { - t.Fatalf("again: %+v", st) - } - for _, p := range store.puts { - if strings.HasPrefix(p, "blob ") { - t.Errorf("a blob the store holds was sent again: %s", p) - } - } -} - -// ADR 0498 §4 / AC: an upstream without a digest fails `not pinned:`, and nothing is read from the upstream. -func TestAnUpstreamWithoutADigestFailsNotPinned(t *testing.T) { - up, store := newMem(t), newMem(t) - upstreamImage(up, "library/nats") - f := newFake(t) - states := newStates() - m := moduleWith(t, f, states) - writeFile(t, m.RegistryFile, store.addr()+"\n") - _, st := runCopy(t, m, states, map[string]any{"module": "nats", "artifact": "server", "from": up.addr() + "/library/nats:2.11.17-alpine"}) - if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "not pinned: server") { - t.Fatalf("step = %+v", st) - } - if len(store.puts) != 0 { - t.Errorf("the store was written: %v", store.puts) - } -} - -// A manifest the upstream serves under another digest than the pin is not copied; the failure is `not stored:`. -func TestAnUpstreamServingAnotherManifestThanPinnedIsNotStored(t *testing.T) { - up, store := newMem(t), newMem(t) - d := upstreamImage(up, "library/nats") - up.manifests["library/nats|"+d] = []byte(`{"tampered":true}`) - f := newFake(t) - states := newStates() - m := moduleWith(t, f, states) - writeFile(t, m.RegistryFile, store.addr()+"\n") - _, st := runCopy(t, m, states, map[string]any{"module": "nats", "artifact": "server", "from": up.addr() + "/library/nats@" + d}) - if st.State != delivery.StepFailed || !strings.HasPrefix(st.Reason, "not stored: server:") { - t.Fatalf("step = %+v", st) - } - if len(store.manifests) != 0 { - t.Errorf("the store holds %v", store.manifests) - } -} - -func TestAShortNameIsQualifiedAsTheHubsAndAHostIsKept(t *testing.T) { +// ADR 0498 §4, design 51 (CAT-BI1): an upstream copy is the image seat's mirror by the pinned digest; the step ends on +// the holder's outcome on the seat's events, answering its reference, and runs no command. A refusal, no outcome in the +// bound and no pin each fail the step, the last before the seat is asked. +func TestTheCopyIsTheImageSeatsMirrorAndEndsOnItsOutcome(t *testing.T) { d := "sha256:" + strings.Repeat("a", 64) - for from, want := range map[string]upstreamRef{ - "alpine@" + d: {"https://registry-1.docker.io", "library/alpine", d}, - "nats:2.11.17-alpine@" + d: {"https://registry-1.docker.io", "library/nats", d}, - "grafana/grafana:11@" + d: {"https://registry-1.docker.io", "grafana/grafana", d}, - "ghcr.io/org/app:1@" + d: {"https://ghcr.io", "org/app", d}, - "localhost:5000/x/y@" + d: {"http://localhost:5000", "x/y", d}, + from := "nats:2.11.17-alpine@" + d + for name, c := range map[string]struct { + from string + mirrored func(reg.MirrorInput) (string, reg.Outcome) + reason string + // elsewhere, watched, atOnce: who hears the outcome and when (the fake's fields of the same names). + elsewhere, watched, atOnce bool + }{ + "published": {from: from}, + "heard by another instance, handed over by the watch of mirror-waits": {from: from, elsewhere: true, watched: true}, + "heard by another instance before this copy read its wait": {from: from, elsewhere: true, atOnce: true}, + "heard by another instance that refused it": {from: from, elsewhere: true, atOnce: true, mirrored: func(in reg.MirrorInput) (string, reg.Outcome) { + return reg.EventRefused, reg.Outcome{Kind: "upstream", Digest: in.Digest, Reason: "the upstream answered 404"} + }, reason: "refused the copy: the upstream answered 404"}, + "refused": {from: from, mirrored: func(in reg.MirrorInput) (string, reg.Outcome) { + return reg.EventRefused, reg.Outcome{Kind: "upstream", Digest: in.Digest, Reason: "the upstream answered 404"} + }, reason: "not stored: server: container-image-registry refused the copy: the upstream answered 404"}, + "another digest's outcome is not this copy's": {from: from, mirrored: func(in reg.MirrorInput) (string, reg.Outcome) { + return reg.EventPublished, reg.Outcome{Kind: "upstream", Digest: "sha256:" + strings.Repeat("b", 64)} + }, reason: "said no outcome of the copy"}, + "a published image is no copy's outcome": {from: from, mirrored: func(in reg.MirrorInput) (string, reg.Outcome) { + return reg.EventPublished, reg.Outcome{Kind: "image", Digest: in.Digest} + }, reason: "said no outcome of the copy"}, + "no pin": {from: "nats:2.11.17-alpine", reason: "not pinned: server"}, } { - got, err := parseUpstream(from) - if err != nil || got != want { - t.Errorf("%s = %+v, %v; want %+v", from, got, err, want) - } + t.Run(name, func(t *testing.T) { + f := newFake(t) + f.mirrored, f.elsewhere, f.watched, f.atOnce = c.mirrored, c.elsewhere, c.watched, c.atOnce + states := newStates() + m := moduleWith(t, f, states) + m.MirrorWait = 200 * time.Millisecond + waits := m.Outcomes.Waits.(*memWaits) + m.ReadWaits = StateRead{Keys: waits.Keys, Get: waits.Get} + var asked []CopyWait + f.atAsk = func() { asked, _ = m.Waiting() } + res, st := runCopy(t, m, states, map[string]any{"module": "nats", "artifact": "server", "from": c.from}) + if c.reason != "" { + if st.State != sdkstep.StepFailed || !strings.Contains(st.Reason, c.reason) { + t.Fatalf("step = %+v, want failed with %q", st, c.reason) + } + if c.reason == "not pinned: server" && len(f.mirrors) != 0 { + t.Errorf("an unpinned copy asked the seat: %v", f.mirrors) + } + return + } + if st.State != sdkstep.StepPassed || res.Digest != d || !res.Pushed || + res.Reference != reg.Seat+"/upstream/nats/server@"+d { + t.Fatalf("step = %+v result = %+v", st, res) + } + if len(f.mirrors) != 1 || f.mirrors[0] != (reg.MirrorInput{Name: "nats/server", Source: from, Digest: d}) { + t.Errorf("mirrors = %+v", f.mirrors) + } + if len(f.lines()) != 0 { + t.Errorf("the copy ran commands: %v", f.lines()) + } + // The read tool of mirror-waits answers the copy's wait while it waits. + if len(asked) != 1 || asked[0].Digest != d || asked[0].From != from || asked[0].Node != "ace" || asked[0].Outcome != nil { + t.Errorf("%s at the ask = %+v", MirrorWaitsTool, asked) + } + if keys, _ := waits.Keys(); len(keys) != 0 { + t.Errorf("an ended copy left its wait in mirror-waits: %v", keys) + } + }) } } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/fetch.go b/modules/mesh-build-image/cmd/mesh-build-image/fetch.go index ed6dde815..92ecb60c5 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/fetch.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/fetch.go @@ -14,7 +14,7 @@ import ( "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) var ( diff --git a/modules/mesh-build-image/cmd/mesh-build-image/fetch_test.go b/modules/mesh-build-image/cmd/mesh-build-image/fetch_test.go index f4d7d3e62..8625c88fa 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/fetch_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/fetch_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // 1703b: the fetch step asks git, through the SDK's FetchCommit, for the ssh remote of the forge as the account, makes @@ -20,11 +20,11 @@ func TestFetchAsksGitForTheSshRemoteIntoAWorkspace(t *testing.T) { if err != nil { t.Fatal(err) } - if a := ans.(map[string]any); a["state"] != delivery.StepRunning || a["step"] == "" { + if a := ans.(map[string]any); a["state"] != sdkstep.StepRunning || a["step"] == "" { t.Errorf("the call answered %v, want a running step", a) } st := states.waitEnd(t) - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("step = %+v", st) } var res FetchResult @@ -60,7 +60,7 @@ func TestFetchRefusesWhatIsNotARepositoryOrACommit(t *testing.T) { if _, err := tool(t, m, FetchTool)(args); err != nil { t.Fatal(err) } - if st := states.waitEnd(t); st.State != delivery.StepFailed { + if st := states.waitEnd(t); st.State != sdkstep.StepFailed { t.Errorf("%s: step = %+v, want failed", name, st) } } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/forge_test.go b/modules/mesh-build-image/cmd/mesh-build-image/forge_test.go index 71c952ef7..553d1e616 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/forge_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/forge_test.go @@ -14,6 +14,7 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -144,7 +145,7 @@ func TestForgeFilesRenderFromTheBoundValuesInTheModulesOwnHome(t *testing.T) { // template in the files/ archive (novox/hq ADR 0349 §2). func TestManifestRequiresForgeAccountKeepsKeysAndTakesFilesFromTemplates(t *testing.T) { m := readForgeManifest(t) - if want := []any{"forge-account", "rootless-image-build", "artifact-store", "node-tools"}; !reflect.DeepEqual(m.Requires, want) { + if want := []any{"forge-account", "rootless-image-build", "node-tools"}; !reflect.DeepEqual(m.Requires, want) { t.Errorf("requires = %v, want %v", m.Requires, want) } keeps := false @@ -235,7 +236,7 @@ func TestTheKeeperWritesTheModulesEntryWithKindBuilder(t *testing.T) { if !ok || len(st.puts) != 1 { t.Fatalf("entries = %v, want one at ace.%s", st.puts, forgeModule) } - var e forgekey.KeyEntry + var e sshkeys.KeyEntry if err := json.Unmarshal(raw, &e); err != nil { t.Fatal(err) } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/helpers_test.go b/modules/mesh-build-image/cmd/mesh-build-image/helpers_test.go index b8f1357cf..024543477 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/helpers_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/helpers_test.go @@ -2,9 +2,10 @@ package main import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" - "net/http" - "net/http/httptest" + "fmt" "os" "path/filepath" "strings" @@ -12,7 +13,10 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + stdio "git.novox.be/novox/mesh-sdk/go" + + reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -21,29 +25,29 @@ const commitSHA = "0123456789abcdef0123456789abcdef01234567" // keptStates is the steps state, kept in memory: every write of a key, and the keys that ended. type keptStates struct { mu sync.Mutex - steps map[string][]delivery.Step + steps map[string][]sdkstep.Step ended chan string } func newStates() *keptStates { - return &keptStates{steps: map[string][]delivery.Step{}, ended: make(chan string, 16)} + return &keptStates{steps: map[string][]sdkstep.Step{}, ended: make(chan string, 16)} } func (s *keptStates) putStep(key string, value any) (uint64, error) { - st, err := delivery.DecodeStep(value.(json.RawMessage)) + st, err := sdkstep.DecodeStep(value.(json.RawMessage)) if err != nil { return 0, err } s.mu.Lock() s.steps[key] = append(s.steps[key], st) s.mu.Unlock() - if st.State != delivery.StepRunning { + if st.State != sdkstep.StepRunning { s.ended <- key } return 1, nil } -func (s *keptStates) waitEnd(t *testing.T) delivery.Step { +func (s *keptStates) waitEnd(t *testing.T) sdkstep.Step { t.Helper() select { case key := <-s.ended: @@ -53,7 +57,7 @@ func (s *keptStates) waitEnd(t *testing.T) delivery.Step { return v[len(v)-1] case <-time.After(5 * time.Second): t.Fatal("the step never ended on its key") - return delivery.Step{} + return sdkstep.Step{} } } @@ -64,46 +68,186 @@ type cmdRecord struct { line string } -// fake stands in for git and buildah at the process boundary and for the registry over HTTP. +// fake stands in for git and buildah at the process boundary and for the image seat's holder at the seat's verbs. type fake struct { mu sync.Mutex calls []cmdRecord - digest string // what `buildah images` prints - pushes int - refuse int // the first n pushes answer "connection refused" + digest string // what `buildah images` prints + pushes int // layouts written failBud forgekey.Ran // what a failing buildah bud answers; a zero Status is a passing bud - // digestFile: "" writes the digest, "none" writes no file, "empty" writes an empty one. + // digestFile: "" writes the layout's digest, "none" writes no file, "other" writes another digest. digestFile string - // registry: repository:tag -> digest - registry map[string]string - server *httptest.Server + // pushed is the manifest digest of the last layout written; parts its parts' digests, the manifest last. + pushed string + parts []string + // The holder: held are the parts it holds already; refuse fails every publish; published the publishes it was + // asked, sent the parts it was sent whole; mirrors the mirrors asked, answered by mirrored (published when nil). + held map[string]bool + refuse error + published []reg.PublishInput + sent []string + mirrors []reg.MirrorInput + mirrored func(reg.MirrorInput) (string, reg.Outcome) + outcomes *Outcomes + // elsewhere: the outcome is heard by another instance of the module over the same mirror-waits, not by this + // process; watched: this process's watch of mirror-waits hands that instance's write over; atOnce: the outcome is + // said before the mirror answers. + elsewhere, watched, atOnce bool + // atAsk runs when the mirror is asked. + atAsk func() +} + +// memWaits is the state mirror-waits in memory; watch, when set, is handed every write as the state's watch hands it. +type memWaits struct { + mu sync.Mutex + m map[string]json.RawMessage + watch func(stdio.StateChange) error +} + +func (w *memWaits) Get(key string) (json.RawMessage, error) { + w.mu.Lock() + defer w.mu.Unlock() + return w.m[key], nil +} + +func (w *memWaits) Put(key string, value any) (uint64, error) { + b, err := json.Marshal(value) + if err != nil { + return 0, err + } + w.mu.Lock() + if w.m == nil { + w.m = map[string]json.RawMessage{} + } + w.m[key] = b + watch := w.watch + w.mu.Unlock() + if watch != nil { + _ = watch(stdio.StateChange{State: MirrorWaitsState, Key: key, Op: "put", Value: b}) + } + return 1, nil +} + +func (w *memWaits) Delete(key string) error { + w.mu.Lock() + defer w.mu.Unlock() + delete(w.m, key) + return nil +} + +func (w *memWaits) Keys() ([]string, error) { + w.mu.Lock() + defer w.mu.Unlock() + keys := []string{} + for k := range w.m { + keys = append(keys, k) + } + return keys, nil } const imageDigest = "sha256:1111111111111111111111111111111111111111111111111111111111111111" func newFake(t *testing.T) *fake { - f := &fake{digest: imageDigest, registry: map[string]string{}} - f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - f.mu.Lock() - defer f.mu.Unlock() - key := strings.TrimPrefix(r.URL.Path, "/v2/") - i := strings.Index(key, "/manifests/") - if r.Method != http.MethodHead || i < 0 { - http.NotFound(w, r) - return - } - d, ok := f.registry[key[:i]+":"+key[i+len("/manifests/"):]] - if !ok { - http.NotFound(w, r) - return - } - w.Header().Set("Docker-Content-Digest", d) - })) - t.Cleanup(f.server.Close) - return f + return &fake{digest: imageDigest, held: map[string]bool{}} } -func (f *fake) address() string { return f.server.Listener.Addr().String() } +// address is the registry part of the seat's reference: the seat's name. +func (f *fake) address() string { return reg.Seat } + +func sha(b []byte) string { + s := sha256.Sum256(b) + return "sha256:" + hex.EncodeToString(s[:]) +} + +// writeLayout writes an OCI layout of one config, one layer and their manifest into dir, as buildah push oci: does. +func (f *fake) writeLayout(dir, digestFile string) { + blobs := filepath.Join(dir, "blobs", "sha256") + _ = os.MkdirAll(blobs, 0o755) + put := func(b []byte) map[string]any { + d := sha(b) + _ = os.WriteFile(filepath.Join(blobs, strings.TrimPrefix(d, "sha256:")), b, 0o644) + return map[string]any{"digest": d, "size": len(b)} + } + config := put([]byte(`{"architecture":"amd64","os":"linux"}`)) + layer := put([]byte("the layer's bytes")) + config["mediaType"], layer["mediaType"] = "application/vnd.oci.image.config.v1+json", "application/vnd.oci.image.layer.v1.tar+gzip" + raw, _ := json.Marshal(map[string]any{"schemaVersion": 2, "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": config, "layers": []any{layer}}) + manifest := put(raw) + manifest["mediaType"] = "application/vnd.oci.image.manifest.v1+json" + index, _ := json.Marshal(map[string]any{"schemaVersion": 2, "manifests": []any{manifest}}) + _ = os.WriteFile(filepath.Join(dir, "index.json"), index, 0o644) + f.pushed = manifest["digest"].(string) + f.parts = []string{config["digest"].(string), layer["digest"].(string), f.pushed} + switch f.digestFile { + case "": + _ = os.WriteFile(digestFile, []byte(f.pushed), 0o644) + case "other": + _ = os.WriteFile(digestFile, []byte(imageDigest), 0o644) + } +} + +// call is the holder's side of the seat's verbs. +func (f *fake) call(key string, body any) (json.RawMessage, error) { + raw, _ := json.Marshal(body) + f.mu.Lock() + defer f.mu.Unlock() + switch key { + case "seat:" + reg.Seat + "." + reg.VerbPublish: + var in reg.PublishInput + _ = json.Unmarshal(raw, &in) + f.published = append(f.published, in) + if f.refuse != nil { + return nil, f.refuse + } + a := reg.PublishAnswer{Transfer: in.Transfer} + for _, p := range in.Parts { + if !f.held[p.Digest] { + a.Lacks = append(a.Lacks, p.Digest) + } + } + a.Held = len(a.Lacks) == 0 + return json.Marshal(a) + case "seat:" + reg.Seat + "." + reg.VerbPublishPart: + var in reg.PublishPartInput + _ = json.Unmarshal(raw, &in) + if in.Last { + f.sent = append(f.sent, in.Part) + } + return json.Marshal(reg.PublishPartAnswer{Transfer: in.Transfer, Part: in.Part, Complete: in.Last}) + case "seat:" + reg.Seat + "." + reg.VerbMirror: + var in reg.MirrorInput + _ = json.Unmarshal(raw, &in) + f.mirrors = append(f.mirrors, in) + if f.atAsk != nil { + f.atAsk() + } + event, o := reg.EventPublished, reg.Outcome{Kind: "upstream", Name: "upstream/" + in.Name, Digest: in.Digest, + Holder: "distribution", Reference: reg.Seat + "/upstream/" + in.Name + "@" + in.Digest} + if f.mirrored != nil { + event, o = f.mirrored(in) + } + if event != "" && f.outcomes != nil { + hearer := f.outcomes + if f.elsewhere { + waits := f.outcomes.Waits.(*memWaits) + if f.watched { + waits.watch = f.outcomes.Changed + } + hearer = &Outcomes{Waits: waits, Node: "g14"} + } + b, _ := json.Marshal(o) + if f.atOnce { + _ = hearer.Heard(event, b) + } else { + // Said after the answer, on the seat's events, as the holder's watch says it. + go func() { _ = hearer.Heard(event, b) }() + } + } + return json.Marshal(reg.MirrorAnswer{Digest: in.Digest, Reference: reg.Seat + "/upstream/" + in.Name + "@" + in.Digest}) + } + return nil, fmt.Errorf("no verb %s", key) +} func (f *fake) run(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { line := name + " " + strings.Join(args, " ") @@ -124,28 +268,17 @@ func (f *fake) run(ctx context.Context, env []string, name string, args ...strin case name == "buildah" && strings.Contains(line, " push "): f.mu.Lock() defer f.mu.Unlock() - if f.refuse > 0 { - f.refuse-- - return forgekey.Ran{Status: 125, Stderr: "Error: dial tcp 127.0.0.1:5000: connect: connection refused\n"} - } f.pushes++ - var digestFile, dest string + var digestFile, dir string for i, a := range args { if a == "--digestfile" { digestFile = args[i+1] } - if strings.HasPrefix(a, "docker://") { - dest = strings.TrimPrefix(a, "docker://") + if strings.HasPrefix(a, "oci:") { + dir = strings.TrimPrefix(a, "oci:") } } - dest = strings.TrimPrefix(dest, f.address()+"/") - f.registry[dest] = f.digest - switch f.digestFile { - case "": - os.WriteFile(digestFile, []byte(f.digest), 0o644) - case "empty": - os.WriteFile(digestFile, nil, 0o644) - } + f.writeLayout(dir, digestFile) } return forgekey.Ran{} } @@ -186,14 +319,13 @@ func moduleWith(t *testing.T, f *fake, states *keptStates) *Module { } m := &Module{ Node: "ace", Account: "mesh-build-image", UID: "1234", Home: t.TempDir(), Work: t.TempDir(), Store: t.TempDir(), - Steps: states.putStep, Ctx: context.Background(), Run: f.run, HTTP: f.server.Client(), - SubUIDFile: write("subuid", "someone:100000:65536\nmesh-build-image:624288:65536\n"), - SubGIDFile: write("subgid", "mesh-build-image:624288:65536\n"), - RegistryFile: write("registry", f.address()+"\n"), - Tun: write("tun", ""), - RegistryWait: 2 * time.Second, - RegistryPause: 5 * time.Millisecond, + Steps: states.putStep, Ctx: context.Background(), Run: f.run, Call: f.call, Outcomes: &Outcomes{Waits: &memWaits{}}, + SubUIDFile: write("subuid", "someone:100000:65536\nmesh-build-image:624288:65536\n"), + SubGIDFile: write("subgid", "mesh-build-image:624288:65536\n"), + Tun: write("tun", ""), + MirrorWait: 5 * time.Second, } + f.outcomes = m.Outcomes return m } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/integration_test.go b/modules/mesh-build-image/cmd/mesh-build-image/integration_test.go index da2d9d717..7bc292767 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/integration_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/integration_test.go @@ -19,7 +19,7 @@ import ( "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) func needTools(t *testing.T, what string, tools ...string) { @@ -91,7 +91,7 @@ func TestFetchOnARealRepository(t *testing.T) { t.Fatal(err) } st := waitLong(t, states) - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("step = %+v", st) } var res FetchResult @@ -106,7 +106,7 @@ func TestFetchOnARealRepository(t *testing.T) { } } -func waitLong(t *testing.T, s *keptStates) delivery.Step { +func waitLong(t *testing.T, s *keptStates) sdkstep.Step { t.Helper() select { case key := <-s.ended: @@ -116,7 +116,7 @@ func waitLong(t *testing.T, s *keptStates) delivery.Step { return v[len(v)-1] case <-time.After(5 * time.Minute): t.Fatal("the step never ended on its key") - return delivery.Step{} + return sdkstep.Step{} } } @@ -133,14 +133,14 @@ func imageModule(t *testing.T, m *Module, dockerfile string, files map[string]st } } -func buildReal(t *testing.T, m *Module, states *keptStates) (BuildResult, delivery.Step) { +func buildReal(t *testing.T, m *Module, states *keptStates) (BuildResult, sdkstep.Step) { t.Helper() if _, err := tool(t, m, BuildTool)(map[string]any{"workspace": "ws1", "module": "modules/img", "artifact": "server"}); err != nil { t.Fatal(err) } st := waitLong(t, states) var res BuildResult - if st.State == delivery.StepPassed { + if st.State == sdkstep.StepPassed { if err := json.Unmarshal(st.Result, &res); err != nil { t.Fatal(err) } @@ -154,7 +154,7 @@ func TestARealBuildAnswersTheImagesDigest(t *testing.T) { m, states := realModule(t) imageModule(t, m, "FROM scratch\nCOPY hello.txt /hello.txt\n", map[string]string{"hello.txt": "hello\n"}) res, st := buildReal(t, m, states) - if st.State != delivery.StepPassed || !digestOK.MatchString(res.Digest) { + if st.State != sdkstep.StepPassed || !digestOK.MatchString(res.Digest) { t.Fatalf("step = %+v result = %+v", st, res) } } @@ -191,17 +191,17 @@ func TestARealBuildIsRootlessWithNoSocketAndNoWayOut(t *testing.T) { "RUN awk 'NR==1 { exit ($2 == 0) }' /proc/self/uid_map\n"+ "RUN ! ls /var/run/docker.sock /run/docker.sock /run/podman/podman.sock /run/containerd/containerd.sock 2>/dev/null\n"+ "RUN ! grep -E 'docker.sock|podman.sock|containerd.sock' /proc/self/mountinfo\n", nil) - if _, st := buildReal(t, m, states); st.State != delivery.StepPassed { + if _, st := buildReal(t, m, states); st.State != sdkstep.StepPassed { t.Errorf("the isolation checks inside the build failed: %+v", st) } // A step that writes outside its workspace fails: the context cannot be left, and the host's kernel files are not writable. imageModule(t, m, "FROM "+base+"\nCOPY ../outside /outside\n", nil) - if _, st := buildReal(t, m, states); st.State != delivery.StepFailed { + if _, st := buildReal(t, m, states); st.State != sdkstep.StepFailed { t.Errorf("a COPY out of the context passed: %+v", st) } imageModule(t, m, "FROM "+base+"\nRUN echo b > /proc/sysrq-trigger\n", nil) - if _, st := buildReal(t, m, states); st.State != delivery.StepFailed { + if _, st := buildReal(t, m, states); st.State != sdkstep.StepFailed { t.Errorf("a write to the host's /proc/sysrq-trigger passed: %+v", st) } else if strings.Contains(st.Reason, "setup network") { t.Errorf("the RUN never started, so the write was not what failed (issue 1703): %s", st.Reason) @@ -220,7 +220,7 @@ func TestARealBuildFindsNoCredentialsOfTheAccount(t *testing.T) { "RUN ! find / -xdev -name mesh_ed25519 2>/dev/null | grep .\n"+ "RUN ! test -e /root/.ssh\n"+ "RUN ! env | grep -E 'REGISTRY_AUTH|MESH_|SSH_AUTH'\n", nil) - if _, st := buildReal(t, m, states); st.State != delivery.StepPassed { + if _, st := buildReal(t, m, states); st.State != sdkstep.StepPassed { t.Errorf("a step found a credential path of the account: %+v", st) } } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/keys.go b/modules/mesh-build-image/cmd/mesh-build-image/keys.go index 85f56c234..5793fab67 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/keys.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/keys.go @@ -13,14 +13,16 @@ import ( "strings" "time" + "git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/forgekey" ) // keeperFor is this module's keeper: go/forgekey's, of kind builder, for account in home on node, whose entry lies // at . of the keys state. -func keeperFor(home, node, account string, run forgekey.Runner, state forgekey.KeyPutter) *forgekey.KeyKeeper { - return &forgekey.KeyKeeper{Home: home, Node: node, Account: account, Kind: forgekey.KindBuilder, Run: run, State: state, Now: time.Now} +func keeperFor(home, node, account string, run forgekey.Runner, state sshkeys.KeyPutter) *sshkeys.KeyKeeper { + return &sshkeys.KeyKeeper{Home: home, Node: node, Account: account, Kind: dforgekey.KindBuilder, Run: run, State: state, Now: time.Now} } // startKeeper runs the keeper when the runtime launched this process for a node: outside a launch — a build, a diff --git a/modules/mesh-build-image/cmd/mesh-build-image/kit_test.go b/modules/mesh-build-image/cmd/mesh-build-image/kit_test.go index 480a11820..9ddec6eac 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/kit_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/kit_test.go @@ -6,14 +6,14 @@ import ( "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/conformance" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" + buildrun "git.novox.be/novox/mesh-delivery/sdk/buildrun" ) // 1890/AC7: the module's own start sweep (endLeftRunning, as main runs it) keeps the conformance kit's left-running // case: a step a previous process left running is ended at once and said, whatever its age, and a step of this process // is kept. func TestTheStartSweepEndsWhatAPreviousProcessLeft(t *testing.T) { - conformance.LeftRunning(t, func(ctx context.Context, scan sdkstep.Scan, node string, before time.Time, logf func(string, ...any)) { + conformance.LeftRunning(t, func(ctx context.Context, scan buildrun.Scan, node string, before time.Time, logf func(string, ...any)) { (&Module{Node: node}).endLeftRunning(ctx, scan, before, logf) }) } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/latepublish.go b/modules/mesh-build-image/cmd/mesh-build-image/latepublish.go new file mode 100644 index 000000000..2e5a03c8b --- /dev/null +++ b/modules/mesh-build-image/cmd/mesh-build-image/latepublish.go @@ -0,0 +1,23 @@ +package main + +// The late publish (novox/hq ADR 0617 §1, CAT-BI1): an image whose floor the builder did not know yet is built +// unnumbered and published by its digest; once the builder numbers it, its version.numbered publishes the held digest +// under that version through the image seat, sending nothing (buildstep/latepublish.go, the build modules' one). + +import ( + "context" + + "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" +) + +// NumberedPattern is the event type this module consumes for the late publish, as its manifest's `consumes` names it. +const NumberedPattern = buildstep.NumberedPattern + +// LatePublish publishes a numbered image's held digest under its version on the image seat; a bundle or an archive is +// mesh-build-go's. +func (m *Module) LatePublish(ctx context.Context, body []byte) (bool, error) { + return buildstep.LatePublish(ctx, m.call(), body, buildstep.ImageSeat, KindImage) +} + +// hearNumbered hears the builder's version.numbered for the late publish. +func (m *Module) hearNumbered(ctx context.Context) { buildstep.HearNumbered(ctx, m.LatePublish) } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/main.go b/modules/mesh-build-image/cmd/mesh-build-image/main.go index 32f75e723..a84f46621 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/main.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/main.go @@ -14,8 +14,10 @@ import ( "strings" "time" + reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildimage" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/delivery" ) // The directories the module's manifest places outside its home (ADR 0466 §1): the workspaces, and buildah's image @@ -31,10 +33,10 @@ func main() { ctx := context.Background() m := &Module{ Node: strings.TrimSpace(os.Getenv("MESH_NODE")), - Steps: stdio.State(delivery.StepsState).Put, - ReadSteps: readOf(stdio.State(delivery.StepsState)), - Pauses: stdio.State(delivery.PausesState).Put, - ReadPauses: readOf(stdio.State(delivery.PausesState)), + Steps: stdio.State(seat.StateSteps).Put, + ReadSteps: readOf(stdio.State(seat.StateSteps)), + Pauses: stdio.State(sdkstep.PausesState).Put, + ReadPauses: readOf(stdio.State(sdkstep.PausesState)), Ctx: ctx, Say: logf, Work: WorkDir, @@ -44,7 +46,9 @@ func main() { if u, err := user.Current(); err == nil { m.Home, m.Account, m.UID = u.HomeDir, u.Username, u.Uid } - m.RegistryFile = m.Home + "/registry" + waits := stdio.State(MirrorWaitsState) + m.Outcomes = &Outcomes{Waits: stateWaits{waits}, Node: m.Node} + m.ReadWaits = readOf(waits) m.Settings = m.Home + "/settings.json" m.Run = StepRunner(m.Home) // Outside a launch by the runtime — a build, a check — there is no node and no state to write. @@ -66,6 +70,14 @@ func main() { // started before booted. go m.endLeftRunning(ctx, m.stepScan(), booted, logf) } + if m.Node != "" { + // The image seat's outcomes, which a copy waits on (the manifest's consumes), and the copies' waits in + // mirror-waits, where another instance writes an outcome it heard. + go hearOutcomes(ctx, m.Outcomes) + // The builder's version.numbered, for the late publish of an image built unnumbered (ADR 0617 §1). + go m.hearNumbered(ctx) + go waits.Follow("", m.Outcomes.Changed) + } if m.Node != "" { // The seat's asks, pulled once Serve has the runtime's channel; a pull before it is said and tried again. go func() { _ = puller.Pull(ctx) }() @@ -75,3 +87,28 @@ func main() { os.Exit(1) } } + +// OutcomeEvents are the image seat's events a copy's end is said by, as the manifest consumes them. +var OutcomeEvents = []string{reg.EventPublished, reg.EventRefused} + +// hearOutcomes subscribes to the image seat's published and refused, asking again until the runtime takes each. +func hearOutcomes(ctx context.Context, o *Outcomes) { + for _, event := range OutcomeEvents { + pattern := reg.Seat + "." + event + go func() { + for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { + err := stdio.Subscribe(pattern, func(e stdio.Envelope) error { return o.Heard(event, e.Body) }) + if err == nil { + logf("listening for %s", pattern) + return + } + logf("not hearing %s yet (%v); asking again in %s", pattern, err, wait) + select { + case <-ctx.Done(): + return + case <-time.After(wait): + } + } + }() + } +} diff --git a/modules/mesh-build-image/cmd/mesh-build-image/manifest_test.go b/modules/mesh-build-image/cmd/mesh-build-image/manifest_test.go index f826d2b98..887c89869 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/manifest_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/manifest_test.go @@ -53,6 +53,14 @@ func resource(t *testing.T, m catalogue.Manifest, id string) map[string]any { // openssh; buildah is not its package (the buildah module's alone). func TestManifestDeclaresTheAccountTheCapabilityAndThePackages(t *testing.T) { m := readManifest(t) + // The key keeper is ssh-client's published package, taken by its series (ADR 0634 §1). + // The builder's step, answer and key forms are read through its published package, taken by its series (ADR 0634 §1). + if m.Packages["builds"] != "0.1" { + t.Errorf("packages %v: want builds of series 0.1", m.Packages) + } + if m.Packages["sshkeys"] != "0.1" { + t.Errorf("packages %v: want sshkeys of series 0.1", m.Packages) + } if m.Module != "mesh-build-image" || m.RunsAs != "mesh-build-image" { t.Errorf("module %q runs as %q, want mesh-build-image as itself", m.Module, m.RunsAs) } @@ -100,20 +108,21 @@ func TestWorkAndStoreDirectoriesLieOutsideTheHome(t *testing.T) { } } -// The states are the two the module keeps, the one seat it claims is its build seat (ADR 0525 §2), the health is its own tool, which the manifest also lists, +// The states are the four the module keeps (mirror-waits a copy's wait for its outcome, CAT-BI1), the one seat it claims is its build seat (ADR 0525 §2), the health is its own tool, which the manifest also lists, // and the registry's address is bound into a file of the home that the program reads. func TestManifestKeepsItsStatesClaimsItsBuildSeatAndDeclaresItsHealthTool(t *testing.T) { m := readManifest(t) states := []string{} for _, s := range m.State { states = append(states, s.Name) - // A step's key is per step and dropped after its age, so the bucket cannot fill; a pause has no age. - if (s.Name == "steps") != (s.TTLSeconds > 0) { - t.Errorf("state %s has ttl-seconds %d: only steps has an age", s.Name, s.TTLSeconds) + // A step's key is per step and dropped after its age, so the bucket cannot fill, and so is a copy's wait, which a + // stopped process leaves; a pause has no age. + if (s.Name == "steps" || s.Name == MirrorWaitsState) != (s.TTLSeconds > 0) { + t.Errorf("state %s has ttl-seconds %d: only steps and %s have an age", s.Name, s.TTLSeconds, MirrorWaitsState) } } slices.Sort(states) - if want := []string{"keys", "pauses", "steps"}; !slices.Equal(states, want) { + if want := []string{"keys", MirrorWaitsState, "pauses", "steps"}; !slices.Equal(states, want) { t.Errorf("state = %v, want %v", states, want) } if len(m.Claims) != 1 || m.Claims[0].Name != Seat || !slices.Equal(m.Claims[0].Serves, []string{"current", "pause", "resume"}) { @@ -123,14 +132,6 @@ func TestManifestKeepsItsStatesClaimsItsBuildSeatAndDeclaresItsHealthTool(t *tes if !strings.Contains(string(raw), `"tool": "`+HealthTool+`"`) { t.Errorf("runtime-health does not name %s", HealthTool) } - reg := resource(t, m, "registry") - if reg["path"] != "${dir:state}/registry" { - t.Errorf("registry file = %v, want ${dir:state}/registry (the program reads /registry)", reg) - } - tpl, _ := os.ReadFile(filepath.Join(moduleDir, "files", "registry")) - if strings.TrimSpace(string(tpl)) != "${seat:mesh-artifact-store:reach}" { - t.Errorf("registry template = %q", tpl) - } raw, _ = os.ReadFile(filepath.Join(moduleDir, "module.json")) var doc struct { Config map[string]struct { diff --git a/modules/mesh-build-image/cmd/mesh-build-image/module.go b/modules/mesh-build-image/cmd/mesh-build-image/module.go index 17e8494e4..69e592da7 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/module.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/module.go @@ -4,13 +4,12 @@ import ( "context" "errors" "fmt" - "net/http" "sync" "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" + buildrun "git.novox.be/novox/mesh-delivery/sdk/buildrun" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/forgekey" "git.novox.be/novox/mesh-sdk/go/health" ) @@ -42,17 +41,18 @@ type Module struct { SubUIDFile, SubGIDFile string // Tun is the tun device buildah's network for a RUN line (pasta) opens; TunDevice when empty. Tun string - // RegistryFile holds the registry's address, host:port, which the manifest binds from the artifact-store provision. - RegistryFile string + // Call asks a seat's verb (the image seat's publish, publish-part and mirror); the runtime's stdio.Ask when nil. + Call buildstep.SeatCall + // Outcomes are the image seat's published and refused as this process hears them, which a copy waits on, kept in the + // state mirror-waits; ReadWaits reads that state for its read tool. + Outcomes *Outcomes + ReadWaits StateRead // RemoteBase is where repositories are fetched from, "ssh://mesh-forge/" when empty; FetchOptions are the options of // the SDK's fetch and merge. A test points them at a local repository (buildstep.AllowFileRemote). RemoteBase string FetchOptions []buildstep.Option - // HTTP talks to the registry; a client of its own when nil. RegistryWait and RegistryPause bound the wait for a - // registry held still (RegistryWait and RegistryFirstPause when zero). - HTTP *http.Client - RegistryWait time.Duration - RegistryPause time.Duration + // MirrorWait bounds a copy's wait for its outcome on the image seat's events (MirrorWait when zero). + MirrorWait time.Duration // Now is the clock; time.Now when nil. Now func() time.Time // Floor refuses every fetch and build while the work or the image store's filesystem has less free than it (issue @@ -164,7 +164,7 @@ func (m *Module) Start(ctx context.Context) { m.Say("this machine cannot build images: %v", err) } m.CleanWorkspaces(m.clock()) - sdkstep.EndStaleRound(m.stepScan(), m.Node, m.Say) // asks past the step bound, on the module's own round (issue 1890) + buildrun.EndStaleRound(m.stepScan(), m.Node, m.Say) // asks past the step bound, on the module's own round (issue 1890) select { case <-ctx.Done(): return @@ -230,8 +230,8 @@ func (m *Module) Long(tool string, run func(ctx context.Context, args map[string if err != nil { return nil, err } - key, _ := delivery.StepKey(m.Node, id) - return map[string]any{"step": id, "key": key, "state": delivery.StepRunning}, nil + key, _ := sdkstep.StepKey(m.Node, id) + return map[string]any{"step": id, "key": key, "state": sdkstep.StepRunning}, nil } } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/module_test.go b/modules/mesh-build-image/cmd/mesh-build-image/module_test.go index fe2c5f2f6..5ac356a5c 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/module_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/module_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/forgekey" "git.novox.be/novox/mesh-sdk/go/health" ) @@ -167,7 +167,7 @@ func TestAStepOnAMachineThatCannotBuildEndsRefused(t *testing.T) { t.Fatal(err) } st := states.waitEnd(t) - if st.State != delivery.StepRefused || !strings.Contains(st.Reason, "/nonexistent/subgid") { + if st.State != sdkstep.StepRefused || !strings.Contains(st.Reason, "/nonexistent/subgid") { t.Errorf("step = %+v, want refused naming the file", st) } for _, l := range f.lines() { diff --git a/modules/mesh-build-image/cmd/mesh-build-image/publish.go b/modules/mesh-build-image/cmd/mesh-build-image/publish.go new file mode 100644 index 000000000..4af9d4eb7 --- /dev/null +++ b/modules/mesh-build-image/cmd/mesh-build-image/publish.go @@ -0,0 +1,151 @@ +package main + +// The publish of a trunk build (novox/hq design 51 *Publishing*, ADR 0611; CAT-BI1): after the build has ended, from +// the module's own process and not from a Dockerfile step, the image is written as an OCI layout in the step's +// workspace and published through the seat container-image-registry's verbs publish and publish-part, which the +// manifest `invokes`, with the build modules' one sender (buildstep.PublishFiles): its config, its layers and its +// manifest, by their digests, of which only the parts the holder lacks travel. This module holds no registry address and +// no credential; the holder checks the build with the builder and says published or refused on the seat's events. The +// step answers the seat's reference, container-image-registry//@sha256:. + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + artifactref "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient/reference" + reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + stdio "git.novox.be/novox/mesh-sdk/go" + + "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" + builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" +) + +// KindImage is the kind an image is published as on its seat. +const KindImage = "image" + +// call is the seat's request: Call when a test set it, else the runtime's request over the bus (stdio.Ask). +func (m *Module) call() buildstep.SeatCall { + if m.Call != nil { + return m.Call + } + return stdio.Ask +} + +// layout is an OCI image layout one build wrote: its one manifest's digest and the files of its parts. +type layout struct { + Digest string + Files []buildstep.File +} + +type descriptor struct { + MediaType string `json:"mediaType"` + Digest string `json:"digest"` + Size int64 `json:"size"` +} + +// readLayout reads the layout in dir: index.json names exactly one manifest, which names its config and layers; every +// part is a blob of the layout, of the size its descriptor says. The manifest comes last, so a holder stores it after +// what it names. +func readLayout(dir string) (layout, error) { + var index struct { + Manifests []descriptor `json:"manifests"` + } + raw, err := os.ReadFile(filepath.Join(dir, "index.json")) + if err != nil { + return layout{}, fmt.Errorf("the image's layout has no index: %w", err) + } + if err := json.Unmarshal(raw, &index); err != nil || len(index.Manifests) != 1 { + return layout{}, fmt.Errorf("the image's layout names %d manifests, not one (%v)", len(index.Manifests), err) + } + top := index.Manifests[0] + blob := func(d descriptor) (buildstep.File, error) { + if !digestOK.MatchString(d.Digest) { + return buildstep.File{}, fmt.Errorf("%q is not a digest", d.Digest) + } + p := filepath.Join(dir, "blobs", "sha256", strings.TrimPrefix(d.Digest, "sha256:")) + info, err := os.Stat(p) + if err != nil { + return buildstep.File{}, fmt.Errorf("the layout lacks the blob %s: %w", d.Digest, err) + } + if info.Size() != d.Size { + return buildstep.File{}, fmt.Errorf("the blob %s holds %d bytes, not the %d its descriptor says", d.Digest, info.Size(), d.Size) + } + return buildstep.File{Digest: d.Digest, Size: d.Size, Path: p}, nil + } + manifest, err := blob(top) + if err != nil { + return layout{}, err + } + var body struct { + Config descriptor `json:"config"` + Layers []descriptor `json:"layers"` + } + raw, err = os.ReadFile(manifest.Path) + if err != nil || json.Unmarshal(raw, &body) != nil || body.Config.Digest == "" { + return layout{}, fmt.Errorf("the manifest %s names no config (%v)", top.Digest, err) + } + out := layout{Digest: top.Digest} + seen := map[string]bool{} + for _, d := range append([]descriptor{body.Config}, body.Layers...) { + if seen[d.Digest] { + continue + } + seen[d.Digest] = true + f, err := blob(d) + if err != nil { + return layout{}, err + } + out.Files = append(out.Files, f) + } + out.Files = append(out.Files, manifest) + return out, nil +} + +// publish writes local as an OCI layout and publishes it on the image seat as /, at the version the +// builder gave it (and the tag the call names): the step answers the layout's manifest digest, checked against the +// digest buildah wrote, and the seat's reference; a publish the holder does not take fails `not stored:`. +func (m *Module) publish(ctx context.Context, workspace, module, local string, res BuildResult, version, tag string) (any, error) { + dir := filepath.Join(m.Work, workspace, "layout."+res.Artifact) + digestFile := filepath.Join(m.Work, workspace, "pushed."+res.Artifact) + _ = os.RemoveAll(dir) + defer os.RemoveAll(dir) + argv := append(m.storeFlags(), "push", "--digestfile", digestFile, local, "oci:"+dir) + if r := m.Run(ctx, m.env(), "buildah", argv...); r.Status != 0 || r.Err != "" { + m.sayFailure("buildah push of "+res.Artifact+" to its layout", r) + return nil, fmt.Errorf("%s%s: writing its layout: %s", builds.NotStored, res.Artifact, cause(r)) + } + img, err := readLayout(dir) + if err != nil { + return nil, fmt.Errorf("%s%s: %w", builds.NotStored, res.Artifact, err) + } + // What buildah wrote is checked against the layout: a digest never goes out unchecked. + raw, err := os.ReadFile(digestFile) + if pushed := strings.TrimSpace(string(raw)); err != nil || pushed != img.Digest { + return nil, fmt.Errorf("%s%s: buildah wrote the digest %q (%v), but its layout's manifest is %s", builds.NotStored, res.Artifact, pushed, err, img.Digest) + } + name := module + "/" + res.Artifact + q := KindImage + if version != "" { + q = "v" + version + } + in := reg.PublishInput{Name: name, Kind: KindImage, Version: version, Tag: tag, Digest: img.Digest, + Transfer: buildstep.TransferName(name, q, img.Digest)} + sent, err := buildstep.PublishFiles(ctx, m.call(), buildstep.ImageSeat, in, img.Files) + if err != nil { + return nil, fmt.Errorf("%s%s: %v", builds.NotStored, res.Artifact, err) + } + m.say("published %s on %s: %d of its %d parts sent, the rest held", name, buildstep.ImageSeat, len(sent.Parts), len(img.Files)) + res.Digest, res.Pushed = img.Digest, true + res.Reference = artifactref.SeatReference(buildstep.ImageSeat, module, res.Artifact, img.Digest) + return res, nil +} + +func (m *Module) say(format string, a ...any) { + if m.Say != nil { + m.Say(format, a...) + } +} diff --git a/modules/mesh-build-image/cmd/mesh-build-image/publish_test.go b/modules/mesh-build-image/cmd/mesh-build-image/publish_test.go new file mode 100644 index 000000000..c7e51a66a --- /dev/null +++ b/modules/mesh-build-image/cmd/mesh-build-image/publish_test.go @@ -0,0 +1,172 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" +) + +// 1703d/AC (ADR 0487 §5): a candidate's build leaves the image seat unchanged: nothing is written, nothing is asked. +func TestACandidatesBuildLeavesTheRegistryUnchanged(t *testing.T) { + m, f, states := setup(t, same) + res, st := runBuild(t, m, states, nil) // no publish + if st.State != sdkstep.StepPassed || res.Pushed { + t.Fatalf("step = %+v result = %+v", st, res) + } + if len(f.called("push")) != 0 || len(f.published) != 0 { + t.Errorf("a candidate's build reached the seat: published %v, calls %v", f.published, f.lines()) + } +} + +// CAT-BI1 (design 51 *Publishing*): a trunk build writes its image as an OCI layout after the build has ended and +// publishes it once through container-image-registry, as / at the ask's version, its config, layers +// and manifest by digest, sending only the parts the holder lacks; the key carries the layout's manifest digest and the +// seat's reference. A holder refusing, a digest file that disagrees and a seat other than the image seat each fail. +func TestATrunkBuildPublishesThroughTheImageSeatOnlyWhatTheHolderLacks(t *testing.T) { + for name, c := range map[string]struct { + extra map[string]any + heldLayer bool + refuse error + digestFile string + reason string + }{ + "every part sent": {extra: map[string]any{"version": "1.4.0"}}, + "the held layer is not sent": {extra: map[string]any{"version": "1.4.0"}, heldLayer: true}, + "a tag of the call's own": {extra: map[string]any{"tag": "fp-abc123"}}, + "the holder refuses": {refuse: errors.New("refused: the builder did not number it"), reason: "not stored: server: "}, + "buildah wrote another digest": {digestFile: "other", reason: "not stored: server: buildah wrote the digest"}, + "buildah wrote no digest": {digestFile: "none", reason: "not stored: server: buildah wrote the digest \"\""}, + "another seat": {extra: map[string]any{"seat": "oci-artifact-registry"}, reason: "is published through container-image-registry"}, + } { + t.Run(name, func(t *testing.T) { + m, f, states := setup(t, same) + f.refuse, f.digestFile = c.refuse, c.digestFile + args := map[string]any{"publish": true} + for k, v := range c.extra { + args[k] = v + } + if c.heldLayer { + // The layout's layer is the fake's one fixed layer: held under any name. + f.held[sha([]byte("the layer's bytes"))] = true + } + res, st := runBuild(t, m, states, args) + if c.reason != "" { + if (st.State != sdkstep.StepFailed && st.State != sdkstep.StepRefused) || !strings.Contains(st.Reason, c.reason) { + t.Fatalf("step = %+v, want failed with %q", st, c.reason) + } + return + } + if st.State != sdkstep.StepPassed { + t.Fatalf("step = %+v", st) + } + if !res.Pushed || res.Digest != f.pushed || res.Reference != reg.Seat+"/fixture-image/server@"+f.pushed { + t.Errorf("result = %+v, want the layout's %s", res, f.pushed) + } + if len(f.published) != 1 { + t.Fatalf("published = %+v", f.published) + } + p := f.published[0] + version, _ := c.extra["version"].(string) + tag, _ := c.extra["tag"].(string) + if p.Name != "fixture-image/server" || p.Kind != "image" || p.Digest != f.pushed || p.Version != version || p.Tag != tag || p.Transfer == "" { + t.Errorf("publish = %+v", p) + } + var parts []string + for _, x := range p.Parts { + parts = append(parts, x.Digest) + } + if !slices.Equal(parts, f.parts) { + t.Errorf("parts = %v, want config, layer, manifest %v", parts, f.parts) + } + want := f.parts + if c.heldLayer { + want = []string{f.parts[0], f.parts[2]} + } + if !slices.Equal(f.sent, want) { + t.Errorf("sent = %v, want %v", f.sent, want) + } + push := f.called("push") + if len(push) != 1 || !strings.Contains(push[0].line, " oci:"+filepath.Join(m.Work, "ws1", "layout.server")) || strings.Contains(push[0].line, "docker://") { + t.Fatalf("push = %v", push) + } + for _, bad := range []string{"--creds", "--authfile", "--sign", "--tls-verify"} { + if strings.Contains(push[0].line, bad) { + t.Errorf("the push carries %s", bad) + } + } + if _, err := os.Stat(filepath.Join(m.Work, "ws1", "layout.server")); !os.IsNotExist(err) { + t.Errorf("the layout outlived the step: %v", err) + } + // The layout is written after the build has ended: bud, then images, then push. + order := []string{} + for _, l := range f.lines() { + for _, v := range []string{" bud ", " images ", " push ", " rmi "} { + if strings.Contains(l, "buildah") && strings.Contains(l, v) { + order = append(order, strings.TrimSpace(v)) + } + } + } + if strings.Join(order, ",") != "bud,images,push,rmi" { + t.Errorf("order = %v", order) + } + }) + } + m2, f2, states2 := setup(t, same) + if _, st := runBuild(t, m2, states2, map[string]any{"publish": true, "tag": "a/b:c"}); st.State != sdkstep.StepFailed || len(f2.called("bud")) != 0 { + t.Errorf("a tag that is not one: step = %+v", st) + } +} + +// ADR 0617 §1: the builder's version.numbered for an image publishes the held digest under its version through the image +// seat, sending nothing; a digest the holder lacks fails the event (offered again); what is not this module's passes. +func TestTheNumberedEventPublishesAHeldImageUnderItsVersion(t *testing.T) { + digest := "sha256:" + strings.Repeat("ab", 32) + numbered := func(kind, seat, digest string) []byte { + raw, err := builds.EncodeVersionNumbered(builds.VersionNumbered{Name: "fixture-image/server", Kind: kind, Version: "1.4.0", + Module: "fixture-image", Fingerprint: "fp", Commit: "c0", Digest: digest, Seat: seat}) + if err != nil { + t.Fatal(err) + } + return raw + } + for name, c := range map[string]struct { + body []byte + did, fail bool + }{ + "a held image is numbered": {body: numbered("image", reg.Seat, digest), did: true}, + "a digest the holder lacks fails": {body: numbered("image", reg.Seat, "sha256:"+strings.Repeat("cd", 32)), fail: true}, + "a bundle is mesh-build-go's": {body: numbered("bundle", "oci-artifact-registry", digest)}, + "an event naming no seat is a bundle's": {body: numbered("image", "", digest)}, + "no digest, nothing to publish": {body: numbered("image", reg.Seat, "")}, + } { + t.Run(name, func(t *testing.T) { + m, f, _ := setup(t, same) + f.held[digest] = true + did, err := m.LatePublish(t.Context(), c.body) + if did != c.did || (err != nil) != c.fail { + t.Fatalf("did %v, err %v; want did %v, fail %v", did, err, c.did, c.fail) + } + if len(f.sent) != 0 { + t.Errorf("a late publish sent parts: %v", f.sent) + } + if !c.did { + return + } + if len(f.published) != 1 { + t.Fatalf("published %+v", f.published) + } + p := f.published[0] + if p.Name != "fixture-image/server" || p.Kind != KindImage || p.Version != "1.4.0" || p.Digest != digest || + p.Fingerprint != "fp" || p.Commit != "c0" || len(p.Parts) != 1 || p.Parts[0].Digest != digest { + t.Errorf("published %+v", p) + } + }) + } +} diff --git a/modules/mesh-build-image/cmd/mesh-build-image/push.go b/modules/mesh-build-image/cmd/mesh-build-image/push.go deleted file mode 100644 index 75c22c500..000000000 --- a/modules/mesh-build-image/cmd/mesh-build-image/push.go +++ /dev/null @@ -1,161 +0,0 @@ -package main - -// The trunk push (novox/hq ADR 0487 §5; issue 1703d): after the build has ended, from the module's own process and not -// from a Dockerfile step, the image is pushed to the registry under a tag of its own and the digest is read back from -// the registry, which is the only one that can say what it serves for the reference (issue 1742). The push and the -// read wait out a registry held still by the store's nightly collection (issue 457). - -import ( - "context" - "errors" - "fmt" - "net/http" - "os" - "path/filepath" - "strings" - "syscall" - "time" - - "git.novox.be/novox/mesh-sdk/go/forgekey" -) - -// RegistryWait is how long a call waits for a registry that refuses connections, longer than the nightly collection -// holds it still (about a minute and a half); RegistryFirstPause is the first pause, doubling to RegistryMostPause. -const ( - RegistryWait = 5 * time.Minute - RegistryFirstPause = time.Second - RegistryMostPause = 10 * time.Second -) - -// registry is the registry's address, host:port, as the manifest bound it into RegistryFile. -func (m *Module) registry() (string, error) { - raw, err := os.ReadFile(m.RegistryFile) - if err != nil { - return "", fmt.Errorf("the registry's address cannot be read: %w", err) - } - addr := strings.TrimSpace(string(raw)) - if addr == "" || strings.Contains(addr, "${") || strings.HasPrefix(addr, ":") || strings.HasSuffix(addr, ":") { - return "", fmt.Errorf("the registry's address in %s is %q, not host:port", m.RegistryFile, addr) - } - return addr, nil -} - -func (m *Module) push(ctx context.Context, workspace, module, local string, res BuildResult, tag string) (any, error) { - addr, err := m.registry() - if err != nil { - return nil, err - } - repository := module + "/" + res.Artifact - dest := addr + "/" + repository + ":" + tag - digestFile := filepath.Join(m.Work, workspace, "pushed."+tag) - argv := append(m.storeFlags(), "push", "--tls-verify=false", "--digestfile", digestFile, local, "docker://"+dest) - var last forgekey.Ran - if err := m.waitForRegistry(ctx, addr, "buildah push "+dest, func() error { - last = m.Run(ctx, m.env(), "buildah", argv...) - if last.Status != 0 || last.Err != "" { - return errors.New(cause(last)) - } - return nil - }); err != nil { - m.sayFailure("buildah push "+dest, last) - return nil, fmt.Errorf("pushing %s: %w", dest, err) - } - var served string - if err := m.waitForRegistry(ctx, addr, "reading "+dest, func() error { - var err error - served, err = m.registryDigest(ctx, addr, repository, tag) - return err - }); err != nil { - return nil, fmt.Errorf("pushed %s and cannot read back what the registry serves for it: %w", dest, err) - } - // A push that succeeded wrote the digest it made: a file missing, unreadable or empty is a fault, and the registry's - // digest is never answered unchecked. - raw, err := os.ReadFile(digestFile) - if err != nil { - return nil, fmt.Errorf("pushed %s, but the digest buildah pushed cannot be read from %s, so the registry's %s cannot be checked: %w", dest, digestFile, served, err) - } - pushed := strings.TrimSpace(string(raw)) - if !digestOK.MatchString(pushed) { - return nil, fmt.Errorf("pushed %s, but the digest file %s holds %q, not a digest, so the registry's %s cannot be checked", dest, digestFile, pushed, served) - } - if pushed != served { - return nil, fmt.Errorf("pushed %s as %s, but the registry serves %s for it", dest, pushed, served) - } - res.Digest, res.Pushed = served, true - res.Reference = addr + "/" + repository + "@" + served - return res, nil -} - -// registryDigest is the digest the registry answers for repository:tag (Docker-Content-Digest of a HEAD). -func (m *Module) registryDigest(ctx context.Context, addr, repository, tag string) (string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodHead, "http://"+addr+"/v2/"+repository+"/manifests/"+tag, nil) - if err != nil { - return "", err - } - req.Header.Set("Accept", strings.Join([]string{ - "application/vnd.oci.image.manifest.v1+json", "application/vnd.oci.image.index.v1+json", - "application/vnd.docker.distribution.manifest.v2+json", "application/vnd.docker.distribution.manifest.list.v2+json"}, ", ")) - client := m.HTTP - if client == nil { - client = &http.Client{Timeout: time.Minute} - } - resp, err := client.Do(req) - if err != nil { - return "", err - } - resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("the registry answered %s for %s:%s", resp.Status, repository, tag) - } - d := resp.Header.Get("Docker-Content-Digest") - if !digestOK.MatchString(d) { - return "", fmt.Errorf("the registry named no digest for %s:%s (Docker-Content-Digest %q)", repository, tag, d) - } - return d, nil -} - -// refused is whether an error is a connection refused: from a dial here, or as buildah said it in its output. -func refused(err error) bool { - return err != nil && (errors.Is(err, syscall.ECONNREFUSED) || strings.Contains(err.Error(), "connection refused")) -} - -// waitForRegistry runs try, and while it fails because the registry at addr refuses connections, tries again with a -// growing pause until RegistryWait has passed (novox/hq issue 457). A refusal is the one error waited for: nothing -// was sent, so trying again does nothing twice. what names the call, for what the module says. -func (m *Module) waitForRegistry(ctx context.Context, addr, what string, try func() error) error { - err := try() - if !refused(err) { - return err - } - limit, pause := m.RegistryWait, m.RegistryPause - if limit == 0 { - limit = RegistryWait - } - if pause == 0 { - pause = RegistryFirstPause - } - started := time.Now() - m.say("%s: refused; waiting for the registry at %s for up to %s — the store's nightly collection holds it still for about a minute and a half (issue 457)", what, addr, limit) - for { - left := limit - time.Since(started) - if left <= 0 { - return fmt.Errorf("the registry at %s refused every connection for %s, longer than its nightly collection holds it still, so it is down, not paused: %w", addr, time.Since(started).Round(time.Millisecond), err) - } - select { - case <-ctx.Done(): - return fmt.Errorf("stopped while waiting for the registry at %s: %w (last: %v)", addr, ctx.Err(), err) - case <-time.After(min(pause, left)): - } - pause = min(pause*2, RegistryMostPause) - if err = try(); !refused(err) { - m.say("%s: the registry at %s answers again after %s", what, addr, time.Since(started).Round(time.Millisecond)) - return err - } - } -} - -func (m *Module) say(format string, a ...any) { - if m.Say != nil { - m.Say(format, a...) - } -} diff --git a/modules/mesh-build-image/cmd/mesh-build-image/push_test.go b/modules/mesh-build-image/cmd/mesh-build-image/push_test.go deleted file mode 100644 index fa882ec24..000000000 --- a/modules/mesh-build-image/cmd/mesh-build-image/push_test.go +++ /dev/null @@ -1,156 +0,0 @@ -package main - -import ( - "context" - "os" - "strings" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/forgekey" -) - -// 1703d/AC (ADR 0487 §5): a candidate's build leaves the registry unchanged: nothing is pushed, nothing is asked of it. -func TestACandidatesBuildLeavesTheRegistryUnchanged(t *testing.T) { - m, f, states := setup(t, same) - res, st := runBuild(t, m, states, nil) // no publish - if st.State != delivery.StepPassed || res.Pushed { - t.Fatalf("step = %+v result = %+v", st, res) - } - if len(f.called("push")) != 0 || len(f.registry) != 0 || f.pushes != 0 { - t.Errorf("a candidate's build reached the registry: pushes %d, registry %v, calls %v", f.pushes, f.registry, f.lines()) - } -} - -// 1703d/AC: a trunk build adds exactly one digest, under / and a tag of its own (the digest's first 12 -// hex digits), after the build has ended, with TLS verification off (plain HTTP) and no credential; the key carries the -// digest the registry serves and the reference pinned by it. -func TestATrunkBuildPushesOnceAndAnswersTheRegistrysDigest(t *testing.T) { - m, f, states := setup(t, same) - res, st := runBuild(t, m, states, map[string]any{"publish": true}) - if st.State != delivery.StepPassed { - t.Fatalf("step = %+v", st) - } - tag := "111111111111" - if len(f.registry) != 1 || f.registry["fixture-image/server:"+tag] != imageDigest || f.pushes != 1 { - t.Fatalf("registry = %v after %d pushes, want exactly fixture-image/server:%s", f.registry, f.pushes, tag) - } - if !res.Pushed || res.Digest != imageDigest || res.Reference != f.address()+"/fixture-image/server@"+imageDigest { - t.Errorf("result = %+v", res) - } - push := f.called("push") - if len(push) != 1 || !strings.Contains(push[0].line, "--tls-verify=false") || !strings.Contains(push[0].line, "docker://"+f.address()+"/fixture-image/server:"+tag) { - t.Fatalf("push = %v", push) - } - for _, bad := range []string{"--creds", "--authfile", "--sign"} { - if strings.Contains(push[0].line, bad) { - t.Errorf("the push carries %s", bad) - } - } - // The push comes after the build has ended: bud, then images, then push. - order := []string{} - for _, l := range f.lines() { - for _, v := range []string{" bud ", " images ", " push ", " rmi "} { - if strings.Contains(l, "buildah") && strings.Contains(l, v) { - order = append(order, strings.TrimSpace(v)) - } - } - } - if strings.Join(order, ",") != "bud,images,push,rmi" { - t.Errorf("order = %v", order) - } -} - -// A tag the call gives is the one used; one that is not a tag is refused. -func TestATagOfTheCallsOwn(t *testing.T) { - m, f, states := setup(t, same) - if _, st := runBuild(t, m, states, map[string]any{"publish": true, "tag": "fp-abc123"}); st.State != delivery.StepPassed { - t.Fatalf("step = %+v", st) - } - if _, ok := f.registry["fixture-image/server:fp-abc123"]; !ok || len(f.registry) != 1 { - t.Errorf("registry = %v", f.registry) - } - m2, f2, states2 := setup(t, same) - if _, st := runBuild(t, m2, states2, map[string]any{"publish": true, "tag": "a/b:c"}); st.State != delivery.StepFailed || len(f2.called("bud")) != 0 { - t.Errorf("step = %+v", st) - } -} - -// issue 457: a registry held still (connection refused) is waited for, and the push then goes through once. -func TestAPushWaitsForARegistryHeldStill(t *testing.T) { - m, f, states := setup(t, same) - f.refuse = 2 - res, st := runBuild(t, m, states, map[string]any{"publish": true}) - if st.State != delivery.StepPassed || !res.Pushed { - t.Fatalf("step = %+v result = %+v", st, res) - } - if f.pushes != 1 || len(f.registry) != 1 || len(f.called("push")) != 3 { - t.Errorf("pushes %d, registry %v, push calls %d; want two refused and one made", f.pushes, f.registry, len(f.called("push"))) - } -} - -// A registry that stays down past the wait fails the step saying how long it was refused; nothing is claimed pushed. -func TestARegistryDownPastTheWaitFailsTheStep(t *testing.T) { - m, f, states := setup(t, same) - f.refuse = 1 << 20 - m.RegistryWait = 50 * time.Millisecond - _, st := runBuild(t, m, states, map[string]any{"publish": true}) - if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "refused every connection") { - t.Errorf("step = %+v", st) - } - if len(f.registry) != 0 { - t.Errorf("registry = %v", f.registry) - } -} - -// The digest the registry serves is the answer: when it differs from the one the push reported, the step fails. -func TestAnAnswerThatDisagreesWithThePushFails(t *testing.T) { - m, f, states := setup(t, same) - run := m.Run - m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { - r := run(ctx, env, name, args...) - if name == "buildah" && strings.Contains(strings.Join(args, " "), " push ") { - f.mu.Lock() - for k := range f.registry { - f.registry[k] = "sha256:2222222222222222222222222222222222222222222222222222222222222222" - } - f.mu.Unlock() - } - return r - } - _, st := runBuild(t, m, states, map[string]any{"publish": true}) - if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "the registry serves") { - t.Errorf("step = %+v", st) - } -} - -func TestTheRegistrysAddressMustBeBound(t *testing.T) { - for _, bad := range []string{"", "${bound:artifact-store:at}:5000", ":5000"} { - m, _, _ := setup(t, same) - writeFile(t, m.RegistryFile, bad) - if _, err := m.registry(); err == nil { - t.Errorf("%q was taken as the registry's address", bad) - } - } -} - -func writeFile(t *testing.T, path, body string) { - t.Helper() - if err := os.WriteFile(path, []byte(body), 0o644); err != nil { - t.Fatal(err) - } -} - -// A push that succeeded wrote its digest file: one missing, unreadable or empty is a fault, and the registry's digest is -// never answered unchecked (review round 1). -func TestAPushWithoutAReadableDigestFileFailsTheStep(t *testing.T) { - for _, kind := range []string{"none", "empty"} { - m, f, states := setup(t, same) - f.digestFile = kind - _, st := runBuild(t, m, states, map[string]any{"publish": true}) - if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "digest") { - t.Errorf("%s: step = %+v, want failed naming the digest file", kind, st) - } - } -} diff --git a/modules/mesh-build-image/cmd/mesh-build-image/read.go b/modules/mesh-build-image/cmd/mesh-build-image/read.go index 550a0d6f3..d0ef53194 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/read.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/read.go @@ -14,8 +14,8 @@ import ( "sync" "time" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/delivery" ) // The read tools, named as the module is: build_image_ and the verb. @@ -97,26 +97,26 @@ func (m *Module) stepKey(id string) (string, error) { } id = rest } - return delivery.StepKey(m.Node, id) + return sdkstep.StepKey(m.Node, id) } // ReadStep is the stored value of this node's step id (state, reason, result). -func (m *Module) ReadStep(id string) (delivery.Step, error) { +func (m *Module) ReadStep(id string) (sdkstep.Step, error) { key, err := m.stepKey(id) if err != nil { - return delivery.Step{}, err + return sdkstep.Step{}, err } if m.ReadSteps.Get == nil { - return delivery.Step{}, fmt.Errorf("the steps state cannot be read here") + return sdkstep.Step{}, fmt.Errorf("the steps state cannot be read here") } raw, err := m.ReadSteps.Get(key) if err != nil { - return delivery.Step{}, err + return sdkstep.Step{}, err } if len(raw) == 0 { - return delivery.Step{}, fmt.Errorf("no step %s in the steps state", key) + return sdkstep.Step{}, fmt.Errorf("no step %s in the steps state", key) } - return delivery.DecodeStep(raw) + return sdkstep.DecodeStep(raw) } // StepLine is a step without its result: the list answers these. @@ -149,7 +149,7 @@ func (m *Module) RecentSteps(limit int) (lines []StepLine, cut bool, err error) own := keys[:0:0] for _, k := range keys { if strings.HasPrefix(k, m.Node+".") { - if node, _, err := delivery.ParseStepKey(k); err == nil && node == m.Node { + if node, _, err := sdkstep.ParseStepKey(k); err == nil && node == m.Node { own = append(own, k) } } @@ -163,12 +163,12 @@ func (m *Module) RecentSteps(limit int) (lines []StepLine, cut bool, err error) if err != nil { return nil, cut, err } - var steps []delivery.Step + var steps []sdkstep.Step for _, raw := range raws { if len(raw) == 0 { continue } - s, err := delivery.DecodeStep(raw) + s, err := sdkstep.DecodeStep(raw) if err != nil { continue // a value this module cannot read is not one of its own steps } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/read_test.go b/modules/mesh-build-image/cmd/mesh-build-image/read_test.go index 61c6fc954..773e9b9b1 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/read_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/read_test.go @@ -8,14 +8,14 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // The read tools answer what the steps state holds: a finished build's result is there, read by the step's id. func TestTheReadToolsAnswerAStepsResult(t *testing.T) { m, _, states := setup(t, same) res, st := runBuild(t, m, states, nil) - if st.State != delivery.StepPassed { + if st.State != sdkstep.StepPassed { t.Fatalf("step = %+v", st) } m.ReadSteps = StateRead{ @@ -36,7 +36,7 @@ func TestTheReadToolsAnswerAStepsResult(t *testing.T) { }, } got, err := m.ReadStep(st.ID) - if err != nil || got.State != delivery.StepPassed { + if err != nil || got.State != sdkstep.StepPassed { t.Fatalf("ReadStep = %+v, %v", got, err) } var back BuildResult @@ -67,7 +67,7 @@ func TestRecentStepsReadsOnlyTheNodesKeysInParallelAndBounded(t *testing.T) { stored := map[string][]byte{} for _, n := range []string{"ace", "g14", "novox", "shanks"} { for i := 0; i < row.perNode; i++ { - b, _ := delivery.EncodeStep(delivery.Step{ID: fmt.Sprintf("s%04d", i), Tool: "t", Node: n, State: delivery.StepPassed, Started: t0.Add(time.Duration(i) * time.Second)}) + b, _ := sdkstep.EncodeStep(sdkstep.Step{ID: fmt.Sprintf("s%04d", i), Tool: "t", Node: n, State: sdkstep.StepPassed, Started: t0.Add(time.Duration(i) * time.Second)}) stored[n+".s"+fmt.Sprintf("%04d", i)] = b } } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/room_test.go b/modules/mesh-build-image/cmd/mesh-build-image/room_test.go index dffa5b1d0..216f0636f 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/room_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/room_test.go @@ -9,7 +9,7 @@ import ( "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/forgekey" "git.novox.be/novox/mesh-sdk/go/health" ) @@ -43,7 +43,7 @@ func TestUnderTheFloorAFetchAndABuildAreRefusedAndTheModuleIsUnhealthy(t *testin if _, err := tool(t, m, name)(args); err != nil { t.Fatalf("%s: %v", name, err) } - if st := states.waitEnd(t); st.State != delivery.StepRefused || !strings.HasPrefix(st.Reason, underTheFloor) { + if st := states.waitEnd(t); st.State != sdkstep.StepRefused || !strings.HasPrefix(st.Reason, underTheFloor) { t.Errorf("%s ended %s: %q, want refused: %q", name, st.State, st.Reason, underTheFloor) } } @@ -87,7 +87,7 @@ func TestAFailedFetchLeavesNoWorkspace(t *testing.T) { if _, err := tool(t, m, FetchTool)(map[string]any{"repository": "novox/mesh-catalog", "commit": commitSHA}); err != nil { t.Fatal(err) } - if st := states.waitEnd(t); st.State != delivery.StepFailed { + if st := states.waitEnd(t); st.State != sdkstep.StepFailed { t.Fatalf("the step ended %s (%s), want failed", st.State, st.Reason) } if entries, _ := os.ReadDir(m.Work); len(entries) != 0 { diff --git a/modules/mesh-build-image/cmd/mesh-build-image/runner.go b/modules/mesh-build-image/cmd/mesh-build-image/runner.go index 717d4f0ac..d11857687 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/runner.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/runner.go @@ -4,6 +4,7 @@ import ( "context" "os" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -14,7 +15,7 @@ import ( // that base given; a command is bound by its context alone (the bound 0), so a fetch may outlast a tool call's thirty // seconds (the step has its own context). func StepRunner(home string) forgekey.Runner { - run := forgekey.ExecRunnerClean(0) + run := dforgekey.ExecRunnerClean(0) return func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { return run(ctx, append(baseEnv(home), env...), name, args...) } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/tools.go b/modules/mesh-build-image/cmd/mesh-build-image/tools.go index 811a2a9f4..4ded89eff 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/tools.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/tools.go @@ -45,6 +45,7 @@ func (m *Module) Tools(steps []StepTool) []stdio.Tool { tools = append(tools, t) } tools = append(tools, m.ReadTools()...) + tools = append(tools, m.mirrorWaitsTool()) tools = append(tools, buildstep.StepEndTool(StepEndTool, m.Node, m.stepScan(), m.Held)) tools = append(tools, m.releaseTool()) return append(tools, diff --git a/modules/mesh-build-image/files/registry b/modules/mesh-build-image/files/registry deleted file mode 100644 index 1bcadab26..000000000 --- a/modules/mesh-build-image/files/registry +++ /dev/null @@ -1 +0,0 @@ -${seat:mesh-artifact-store:reach} diff --git a/modules/mesh-build-image/go.mod b/modules/mesh-build-image/go.mod index 38b96cbee..9795b1b99 100644 --- a/modules/mesh-build-image/go.mod +++ b/modules/mesh-build-image/go.mod @@ -3,23 +3,43 @@ module meshbuildimage go 1.26.0 require ( - git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0 - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildimage v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) require ( - github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect + github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/minio/highwayhash v1.0.4 // indirect - github.com/nats-io/jwt/v2 v2.8.1 // indirect + github.com/nats-io/jwt/v2 v2.8.2 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect golang.org/x/crypto v0.57.0 // indirect golang.org/x/sys v0.48.0 // indirect - golang.org/x/time v0.15.0 // indirect + golang.org/x/time v0.16.0 // indirect ) replace git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep => ../mesh-build-go/buildstep + +require git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys v0.0.0-00010101000000-000000000000 + +replace git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys => ../ssh-client/sshkeys + +require ( + git.novox.be/novox/mesh-delivery/contracts/go/clients/gopackageregistryclient v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/clients/npmpackageregistryclient v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/clients/ociartifactregistryclient v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/seats/gopackageregistry v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/seats/npmpackageregistry v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000 // indirect +) diff --git a/modules/mesh-build-image/go.sum b/modules/mesh-build-image/go.sum index 43cc4ec77..8ca93dfd0 100644 --- a/modules/mesh-build-image/go.sum +++ b/modules/mesh-build-image/go.sum @@ -1,15 +1,13 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= -github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= -github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc= +github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc= github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= @@ -23,5 +21,5 @@ golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZ golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= diff --git a/modules/mesh-build-image/module.json b/modules/mesh-build-image/module.json index 54bc2fb0a..a59828c11 100644 --- a/modules/mesh-build-image/module.json +++ b/modules/mesh-build-image/module.json @@ -1,6 +1,10 @@ { "module": "mesh-build-image", "version": "1", + "packages": { + "builds": "0.1", + "sshkeys": "0.1" + }, "moved": true, "runs-as": "mesh-build-image", "capabilities": [ @@ -9,7 +13,8 @@ ], "state": [ "keys", - {"name": "steps", "ttl-seconds": 172800}, "pauses" + {"name": "steps", "ttl-seconds": 172800}, "pauses", + {"name": "mirror-waits", "ttl-seconds": 172800} ], "claims": [ { @@ -42,6 +47,7 @@ "build_image_fetch", "build_image_build", "build_image_copy", + "build_image_mirror_waits", "build_image_release", "build_image_step", "build_image_steps", @@ -52,9 +58,18 @@ "requires": [ "forge-account", "rootless-image-build", - "artifact-store", "node-tools" ], + "invokes": [ + "seat:container-image-registry.publish", + "seat:container-image-registry.publish-part", + "seat:container-image-registry.mirror" + ], + "consumes": [ + "mesh-delivery-builder.version.numbered", + "container-image-registry.published", + "container-image-registry.refused" + ], "own-secrets": { "broker": "${dir:state}/broker" }, @@ -142,14 +157,6 @@ "mode": "0644", "template": "files/gitconfig" }, - { - "id": "registry", - "type": "file", - "path": "${dir:state}/registry", - "owner": "mesh-build-image", - "mode": "0644", - "template": "files/registry" - }, { "id": "git", "type": "package", diff --git a/modules/mesh-build-node/README.md b/modules/mesh-build-node/README.md index 9f1b1a54b..c0307486e 100644 --- a/modules/mesh-build-node/README.md +++ b/modules/mesh-build-node/README.md @@ -24,9 +24,9 @@ the module runs `npm ci --ignore-scripts` there once (a directory without its `. **How the registry is reached** (the choice of issue 1488): the lock holds @novox/mesh-sdk at the forge's public address, and npm 12 installs a locked tarball only from the registry it is configured with. The module therefore -requires `npm-package-registry` (read access, provided by gitea) and, like the lab module, takes its binding and sealed -secret as files in its home. The install gets a private npm config file with the `@novox` scope at the binding's -`public-root` (else scheme, address, port and npm path) and the bound account's credential, and removes it afterwards; +requires `npm-package-registry` (provided by gitea, form none: reads are anonymous) and, like the lab module, takes its +binding as a file in its home. The install gets a private npm config file with the `@novox` scope at the binding's +`public-root` (else scheme, address, port and npm path), with no credential, and removes it afterwards; every other package resolves from the public registry. This is the way today's build agent names it. The runtime tree for bundles with externals (the pruned sdk and nats today in `/app/runtime`) is not installed @@ -83,14 +83,16 @@ toolchain install: a long step answers at once with its id and ends on `.< toolchain's packages (not typescript or esbuild) copied to `stage-/node_modules`: the build agent's depth (`/app/modules/module`, `/app/node_modules`), so esbuild's `../../node_modules/...` path comments, and the bundle's digest, equal its. Copies, not links, because the mesh makes no symlink. A bundle that keeps an external gets them in its own `node_modules`. - Once the build ended, this process pushes the packed archive to the artifact store (novox/hq ADR 0498 §1 §2, mesh-sdk's - `buildstep.PushBlob`, the address in `${dir:state}/registry`) under `/` by its digest, a candidate's as a - trunk build's, and answers `reference: artifact-store:////blobs/`; a push that fails fails the - step `not stored: : `. The packed bytes never stay on the node. tsc and esbuild, like the + Once the build ended, this process publishes the packed archive through the seat `oci-artifact-registry` (design 51 + *Publishing*; buildstep's one sender, `buildstep.PublishOne`, the manifest `invokes` publish and publish-part) under + `/` by its digest, a candidate's as a trunk build's, and answers the seat's reference + `oci-artifact-registry//@`; a publish the holder refuses, or a seat nothing holds, fails the + step `not stored: : `. The packed bytes never stay on the node. tsc and esbuild, like the type-check and the test script, run shut off in bubblewrap (ADR 0448): no network, none of the account, the toolchain read-only, the npm cache beneath a throwaway layer unless the step is told `publish` (a trunk build). A `package` artifact (ADR 0498 §5) is built by its own `build` script and packed with `npm pack --ignore-scripts`, both - shut off; its digest is the packed file's. Only with `publish` is it published, by this process with the registry's - write credential (never in a workspace or a step's mounts): a version the registry lacks is published, the same - packed file already there is done without a publish, another file under that version fails `version taken: - @`; the reference `@` is answered only then. A candidate answers its digest alone. + shut off; its digest is the packed file's. A trunk build the builder gave a version packs it under that version (its + package.json's `version` set for the pack, then put back) and publishes it through `npm-package-registry`; the holder + asks the builder, answers held for the same file and refuses another under that version. The reference + `@` is answered only then. A candidate, or a trunk build not numbered, answers its digest alone. + The module holds no credential for the registry: reads are anonymous (ADR 0346) and writes go through the seat. diff --git a/modules/mesh-build-node/cmd/mesh-build-node/asks.go b/modules/mesh-build-node/cmd/mesh-build-node/asks.go index c723ecafb..9afc6a576 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/asks.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/asks.go @@ -2,12 +2,14 @@ package main import ( "context" + "path/filepath" "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildnode" + buildrun "git.novox.be/novox/mesh-delivery/sdk/buildrun" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" stdio "git.novox.be/novox/mesh-sdk/go" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" ) // **The module pulls its seat's asks** (novox/hq ADR 0525 §2-§6, ADR 0536 §4; issue 1887 A3node), as mesh-build-go @@ -29,12 +31,13 @@ func (m *Module) askSteps(steps []StepTool) buildstep.AskSteps { } return buildstep.AskSteps{ Fetch: byName["build_node_fetch"], Build: byName["build_node_build"], Test: byName["build_node_test"], - Toolchain: func(ctx context.Context) (delivery.ToolchainUsed, error) { + Compare: buildstep.CompareStep(buildstep.GeneratedRootTS, func(ws string) string { return filepath.Join(m.Work, ws, "tree") }), + Toolchain: func(ctx context.Context) (sdkstep.ToolchainUsed, error) { installed, err := m.Probe(ctx) if err != nil { - return delivery.ToolchainUsed{}, err + return sdkstep.ToolchainUsed{}, err } - return delivery.ToolchainUsed{Version: installed, Recipe: m.recipe().Version()}, nil + return sdkstep.ToolchainUsed{Version: installed, Recipe: m.recipe().Version()}, nil }, Release: func(ws string) error { return m.spaces().Release(ws) }, Logf: m.Say, @@ -44,7 +47,7 @@ func (m *Module) askSteps(steps []StepTool) buildstep.AskSteps { // Puller is the module's pull of its seat's asks, on source (the runtime's seat pull when nil). func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep.Puller { if source == nil { - source = buildstep.SeatAsks{Language: delivery.AskNode} + source = buildstep.SeatAsks{Language: sdkstep.AskNode} } capacity := m.Capacity // The capacity's own change wakes the puller (issue 1887): the runtime's watch of the config value as mesh-config @@ -62,16 +65,16 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep capacity = buildstep.NodeCapacity(m.Settings, m.Node, m.Say) settings = m.Settings watch = buildstep.WatchConfigServed - cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[delivery.AskNode], m.Say) + cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[sdkstep.AskNode], m.Say) } ask := m.askSteps(steps) var load func(string) ([]byte, error) if m.ReadPauses.Get != nil { load = func(key string) ([]byte, error) { return m.ReadPauses.Get(key) } } - return &buildstep.Puller{Source: source, Language: delivery.AskNode, Node: m.Node, Put: m.Steps, PausePut: m.Pauses, Load: load, + return &buildstep.Puller{Source: source, Language: sdkstep.AskNode, Node: m.Node, Put: m.Steps, PausePut: m.Pauses, Load: load, Capacity: capacity, SettingsFile: settings, WatchConfig: watch, CoresPerStep: cores, BuildHours: hours, Logf: m.Say, - Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { + Run: func(ctx context.Context, a sdkstep.StepAsk) (any, error) { if err := m.Prepare(ctx); err != nil { return nil, err } @@ -91,13 +94,13 @@ func (m *Module) SeatTools(p *buildstep.Puller) []stdio.Tool { const StepEndTool = "build_node_step_end" // endLeftRunning is the module's start sweep (issue 1890): it ends the steps of its node that a previous process left -// running (sdkstep.EndStaleAtStart; every step names its owner). main runs it once at the start, and the module's test +// running (buildrun.EndStaleAtStart; every step names its owner). main runs it once at the start, and the module's test // runs it through the conformance kit's left-running case. -func (m *Module) endLeftRunning(ctx context.Context, scan sdkstep.Scan, booted time.Time, logf func(string, ...any)) { - sdkstep.EndStaleAtStart(ctx, scan, m.Node, booted, logf) +func (m *Module) endLeftRunning(ctx context.Context, scan buildrun.Scan, booted time.Time, logf func(string, ...any)) { + buildrun.EndStaleAtStart(ctx, scan, m.Node, booted, logf) } // stepScan is the module's steps state as buildstep reads it. -func (m *Module) stepScan() sdkstep.Scan { - return sdkstep.ScanOf(stdio.State(delivery.StepsState)) +func (m *Module) stepScan() buildrun.Scan { + return buildrun.ScanOf(stdio.State(seat.StateSteps)) } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/asks_test.go b/modules/mesh-build-node/cmd/mesh-build-node/asks_test.go index 0c7386fd0..3b02e0b23 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/asks_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/asks_test.go @@ -11,8 +11,7 @@ import ( "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) const askCommit = "0123456789abcdef0123456789abcdef01234567" @@ -31,14 +30,14 @@ func fakeSteps(calls *[]string) []StepTool { } } -func nodeAsk(kind string) delivery.StepAsk { - a := delivery.StepAsk{ID: sdkstep.NewID(), Kind: kind, Language: delivery.AskNode, Asker: "mesh-delivery-checker", At: time.Now()} - fetch := map[string]any{"repository": "novox/mesh-catalog", "commit": askCommit} - if kind == delivery.AskDelivery { +func nodeAsk(kind string) sdkstep.StepAsk { + a := sdkstep.StepAsk{ID: sdkstep.NewID(), Kind: kind, Language: sdkstep.AskNode, Asker: "mesh-delivery-checker", At: time.Now()} + fetch := sdkstep.Fetch{Repository: "novox/mesh-catalog", Commit: askCommit} + if kind == sdkstep.AskDelivery { a.Asker = "mesh-delivery-builder" - a.Build = &delivery.BuildJob{Module: "gitea", Path: "modules/gitea", Fetch: fetch, Artifacts: []delivery.AskArtifact{{Artifact: "code"}}} + a.Build = &sdkstep.BuildJob{Module: "gitea", Path: "modules/gitea", Fetch: &fetch, Artifacts: []sdkstep.AskArtifact{{Artifact: "code"}}} } else { - a.Check = &delivery.CheckJob{Fetch: fetch, Levels: []string{delivery.LevelUnit}} + a.Check = &sdkstep.CheckJob{Fetch: fetch, Levels: []string{sdkstep.LevelUnit}} } return a } @@ -51,7 +50,7 @@ func TestAPulledNodeAskRunsItsJob(t *testing.T) { m.Capacity = func() int { return 1 } var calls []string src := buildstep.NewFakeAsks() - build, check := nodeAsk(delivery.AskDelivery), nodeAsk(delivery.AskCheck) + build, check := nodeAsk(sdkstep.AskDelivery), nodeAsk(sdkstep.AskCheck) _ = src.PublishAsk(check) _ = src.PublishAsk(build) p := m.Puller(fakeSteps(&calls), src) @@ -70,7 +69,7 @@ func TestAPulledNodeAskRunsItsJob(t *testing.T) { if got := strings.Join(calls, ","); got != "build_node_fetch,build_node_build,build_node_fetch,build_node_test" { t.Errorf("calls = %s, want the delivery ask's job first, then the check's", got) } - key, _ := delivery.AskStepKey("ace", build.ID) + key, _ := sdkstep.AskStepKey("ace", build.ID) states.mu.Lock() v := states.steps[key] states.mu.Unlock() @@ -92,10 +91,10 @@ func TestManifestTakesTheBuildCapacity(t *testing.T) { } _ = json.Unmarshal(raw, &doc) // Issue 1806's follow-up: the cores a step needs is a setting of its own, in the settings file, with the SDK's default. - if cores := string(doc.Config[buildstep.CoresPerStepSetting].Default); cores != fmt.Sprint(buildstep.DefaultCoresPerStep[delivery.AskNode]) { - t.Errorf("the cores-per-step default = %s, want %g", cores, buildstep.DefaultCoresPerStep[delivery.AskNode]) + if cores := string(doc.Config[buildstep.CoresPerStepSetting].Default); cores != fmt.Sprint(buildstep.DefaultCoresPerStep[sdkstep.AskNode]) { + t.Errorf("the cores-per-step default = %s, want %g", cores, buildstep.DefaultCoresPerStep[sdkstep.AskNode]) } - if got := buildstep.NodeCoresPerStep(moduleDir+"/files/settings.json", 0, nil)(); got != buildstep.DefaultCoresPerStep[delivery.AskNode] { + if got := buildstep.NodeCoresPerStep(moduleDir+"/files/settings.json", 0, nil)(); got != buildstep.DefaultCoresPerStep[sdkstep.AskNode] { t.Errorf("the settings template holds cores-per-step %g", got) } // And the build hours (the operator, 2026-10-26): a setting of their own, empty by default (any hour), in the template. diff --git a/modules/mesh-build-node/cmd/mesh-build-node/build.go b/modules/mesh-build-node/cmd/mesh-build-node/build.go index 086bd7c01..00a966817 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/build.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/build.go @@ -19,8 +19,9 @@ import ( "strings" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" + artifactref "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient/reference" + builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // Out is where a bundle's compiled files go in the module's directory (builder toolchain.go:118). @@ -110,8 +111,10 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) { return nil, err } if a.Kind == "package" { + version, _ := args["version"].(string) + seat, _ := args["seat"].(string) at := a.packageDir(dir) - return m.buildPackage(ctx, workspace, module, root, filepath.Join(dir, filepath.FromSlash(at)), at, a, publish) + return m.buildPackage(ctx, workspace, module, root, filepath.Join(dir, filepath.FromSlash(at)), at, a, publish, version, seat) } if why, err := lockProblem(dir, module); err != nil || why != "" { if err == nil { @@ -162,10 +165,10 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) { if err != nil { return nil, fmt.Errorf("packing %s: %w", a.Name, err) } - // Pushed from this process once the build ended, a candidate's as a trunk build's (ADR 0498 §1 §2): the step answers - // the reference, or fails not stored. The packed bytes are never written to the node (the lead's decision L9). - if err := m.push(ctx, a.module+"/"+a.Name, digest, archive); err != nil { - return nil, fmt.Errorf("%s%s: %v", delivery.NotStored, a.Name, err) + // Published from this process once the build ended, a candidate's as a trunk build's (ADR 0498 §1 §2; CAT-BN3): the + // step answers the seat's reference, or fails not stored. The packed bytes are never written to the node (L9). + if err := m.publishOn(ctx, buildstep.ArtifactSeat, a.module+"/"+a.Name, "bundle", "", digest, archive); err != nil { + return nil, fmt.Errorf("%s%s: %v", builds.NotStored, a.Name, err) } files := []string{} _ = filepath.WalkDir(bundleDir, func(p string, d os.DirEntry, err error) error { @@ -177,7 +180,7 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) { }) sort.Strings(files) return BuildResult{Workspace: workspace, Module: module, Artifact: a.Name, Digest: digest, Bytes: len(archive), - Reference: buildstep.BlobReference(a.module, a.Name, digest), Files: files}, nil + Reference: artifactref.SeatReference(buildstep.ArtifactSeat, a.module, a.Name, digest), Files: files}, nil } // bundle makes every entrypoint and every launcher ONE file, with esbuild (builder.go:1971-2039): an entrypoint is diff --git a/modules/mesh-build-node/cmd/mesh-build-node/build_test.go b/modules/mesh-build-node/cmd/mesh-build-node/build_test.go index 767042f0f..43fe83cc0 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/build_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/build_test.go @@ -13,7 +13,7 @@ import ( "strings" "testing" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/forgekey" ) diff --git a/modules/mesh-build-node/cmd/mesh-build-node/check.go b/modules/mesh-build-node/cmd/mesh-build-node/check.go index cda4b1ca5..9d068230e 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/check.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/check.go @@ -22,7 +22,7 @@ import ( "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -60,6 +60,9 @@ type TestResult struct { // By is the build module and the SDK it was built with (sdkstep.By): the checker names it when a failure hides // its cause. By string `json:"by,omitempty"` + // OutOfStep are the generated TypeScript files of a contracts module out of step with the seat generator (CAT-BN2, + // buildstep.OutOfStep): the unit level fails on any, Why naming each. Absent when none is. + OutOfStep []string `json:"out-of-step,omitempty"` } // Failure is the SDK's failure contract (sdkstep.Failure, issue 1901): a failing test of the test script, read from @@ -90,6 +93,29 @@ func (m *Module) testTool() StepTool { } func (m *Module) test(ctx context.Context, args map[string]any) (any, error) { + res, err := m.testLevel(ctx, args) + if err != nil || res.Level != "unit" { + return res, err + } + // CAT-BN2: a contracts module's generated TypeScript is regenerated and compared; a file out of step fails the level. + _, dir, err := m.moduleDirOf(res.Workspace, res.Module) + if err != nil { + return nil, err + } + if res.OutOfStep, err = buildstep.OutOfStep(dir, buildstep.GeneratedRootTS); err != nil { + return nil, err + } + if len(res.OutOfStep) > 0 { + why := "generated files out of step with the seat generator (regenerate them): " + strings.Join(res.OutOfStep, ", ") + if res.Why != "" && !res.NoTests { + why = res.Why + "; " + why + } + res.Passed, res.NoTests, res.Why = false, false, why + } + return res, nil +} + +func (m *Module) testLevel(ctx context.Context, args map[string]any) (*TestResult, error) { workspace, _ := args["workspace"].(string) module, _ := args["module"].(string) level, _ := args["level"].(string) diff --git a/modules/mesh-build-node/cmd/mesh-build-node/check_test.go b/modules/mesh-build-node/cmd/mesh-build-node/check_test.go index f1329d34b..21528298d 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/check_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/check_test.go @@ -79,6 +79,28 @@ func TestAPackageWithNeitherScriptNorTypeScriptHasNoTests(t *testing.T) { if len(f.calls) != 0 { t.Errorf("%d commands ran for a package with no tests", len(f.calls)) } + // CAT-BN2: a contracts module (a package artifact naming generated-from) with no tests of its own still fails its + // unit level on a generated TypeScript file out of step with the seat generator, each named; its Go files are + // mesh-build-go's to compare. + m, _, work = newMachine(t) + workspaceWith(t, work, map[string]string{ + "module.json": `{"module":"c","build":{"artifacts":[{"name":"ts","kind":"package","language":"typescript","paths":["ts"],"generated-from":"."}]}}`, + "seats/one-store/seat.json": `{"format":1,"seat":"one-store","scope":"mesh","grain":"seat-list","domain":"Module","decision":"ADR 1","verbs":[{"name":"query","input":{"type":"object"}}]}`, + "go/go.mod": "module example.org/c/go\n", "go/stray.go": "package stray\n", + "ts/package.json": `{"name":"@example/c"}`, "ts/stray.ts.txt": "not written by the generator\n", + }) + out, err := m.test(context.Background(), testArgs("unit")) + r := out.(*TestResult) + stray := false + for _, p := range r.OutOfStep { + stray = stray || p == "ts/stray.ts.txt" + if !strings.HasPrefix(p, "ts/") { + t.Errorf("%s is not under the TypeScript root", p) + } + } + if err != nil || r.Passed || r.NoTests || !stray || !strings.Contains(r.Why, "ts/stray.ts.txt") { + t.Errorf("a contracts module out of step = %+v, %v", r, err) + } } // 1491/AC4: with test:integration present the integration level runs it, and reports the setup (the type-check) and diff --git a/modules/mesh-build-node/cmd/mesh-build-node/fetch.go b/modules/mesh-build-node/cmd/mesh-build-node/fetch.go index bd6343ecd..1f5ebc28c 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/fetch.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/fetch.go @@ -25,7 +25,8 @@ import ( "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -94,9 +95,9 @@ func (m *Module) fetchTool() StepTool { "ref": map[string]any{"type": "string", "description": "refs/pull//head when only a pull request's head carries the commit"}, "dir": sdkstep.FetchRequestProperties()["dir"], "paths": sdkstep.FetchRequestProperties()["paths"], - "package": map[string]any{"type": "object", "description": "optional: the group's package member (ADR 0500 §5), packed here, publishing nothing, and served to each TypeScript member whose lock names its version, in place of the registry's", - "properties": map[string]any{"repository": map[string]any{"type": "string"}, "commit": map[string]any{"type": "string"}, "ref": map[string]any{"type": "string"}}, - "required": []string{"repository", "commit"}}, + "package": map[string]any{"type": "object", "description": "optional: the group's tagged version of the SDK, {name, version}, placed from the registry in each TypeScript member whatever its lock names (ADR 0607 §3); or the group's package member, {repository, commit, ref} (ADR 0500 §5), packed here, publishing nothing, and served to each TypeScript member whose lock names its version, in place of the registry's", + "properties": map[string]any{"name": map[string]any{"type": "string"}, "version": map[string]any{"type": "string"}, + "repository": map[string]any{"type": "string"}, "commit": map[string]any{"type": "string"}, "ref": map[string]any{"type": "string"}}}, "base": map[string]any{"type": "string", "description": "optional trunk commit, 40 hex digits: the head is merged onto it as the forge's merge would, and the answer carries the merge commit, the files in conflict (an answer, the workspace is then left at the trunk commit), the changed paths and the tree of each module directory"}, }, "required": []string{"repository", "commit"}, @@ -169,7 +170,7 @@ func (m *Module) fetchInto(ctx context.Context, req sdkstep.FetchRequest, group } // The group's package, packed here and served to its members in place of the registry's version (ADR 0500 §5). var served *packed - if group != nil { + if group != nil && !group.tagged() { p, err := m.packGroup(ctx, root, gitRun, group) if err != nil { return nil, err @@ -197,7 +198,7 @@ func (m *Module) fetchInto(ctx context.Context, req sdkstep.FetchRequest, group } if refused == "" { // The SDK the module's own lock names, from the group or the registry (the lead's decision L10). - if err := m.placeSDK(ctx, dir, d, served); err != nil { + if err := m.placeSDK(ctx, dir, d, served, group); err != nil { return nil, err } } @@ -216,7 +217,7 @@ func (m *Module) fetchInto(ctx context.Context, req sdkstep.FetchRequest, group func (m *Module) typeScriptModules(ctx context.Context, tree string) (dirs []string, packages map[string]bool, err error) { r := m.Run(ctx, m.baseEnv(), "git", "-C", tree, "ls-files", "--", "module.json", "*/module.json") if r.Status != 0 || r.Err != "" { - return nil, nil, fmt.Errorf("git ls-files: %s", forgekey.Said(r.Err+"\n"+r.Stderr)) + return nil, nil, fmt.Errorf("git ls-files: %s", dforgekey.Said(r.Err+"\n"+r.Stderr)) } packages = map[string]bool{} for _, l := range strings.Split(r.Stdout, "\n") { diff --git a/modules/mesh-build-node/cmd/mesh-build-node/fetch_test.go b/modules/mesh-build-node/cmd/mesh-build-node/fetch_test.go index ee023a450..76bda1a28 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/fetch_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/fetch_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -192,7 +193,7 @@ func TestAnotherNovoxPackageIsInstalledFromTheRegistryThroughAUserconfig(t *test files["modules/m/package-lock.json"] = `{}` m, f := fetchMachine(t, files, map[string]string{"@novox/other/index.js": "o", "lodash/index.js": "l"}) m.Toolchain.Registry = func() (Registry, error) { - return Registry{URL: "https://git.novox.be/api/packages/novox/npm/", Username: "mesh-build-node", Password: "s3cret-pw!"}, nil + return Registry{URL: "https://git.novox.be/api/packages/novox/npm/"}, nil } var npmrc string inner := f.effect @@ -209,20 +210,17 @@ func TestAnotherNovoxPackageIsInstalledFromTheRegistryThroughAUserconfig(t *test if err != nil { t.Fatal(err) } - if !strings.Contains(npmrc, "@novox:registry=https://git.novox.be/api/packages/novox/npm/") || !strings.Contains(npmrc, ":_auth=") { + // Reads are anonymous (ADR 0346, CAT-BN3): the config names the scope's registry and no credential. + if !strings.Contains(npmrc, "@novox:registry=https://git.novox.be/api/packages/novox/npm/") || strings.Contains(npmrc, "_auth") { t.Errorf("npm ci was given the config %q", npmrc) } - // The password holds characters a workspace's random name never does, so a name like "mvpwby…" is no false match. - if strings.Contains(strings.Join(npmCalls(f)[0].args, " "), "s3cret-pw!") { - t.Errorf("the credential is on npm's command line") - } r := out.(FetchResult) if _, err := os.Stat(filepath.Join(m.Work, r.Workspace, "tree", "modules", "m", "node_modules", "@novox", "other", "index.js")); err != nil { t.Errorf("@novox/other was not installed: %v", err) } for _, e := range mustReadDir(t, filepath.Join(m.Work, r.Workspace)) { if strings.HasPrefix(e, "npm-") { - t.Errorf("the scratch install (and its credential file) is left: %s", e) + t.Errorf("the scratch install is left: %s", e) } } } @@ -272,7 +270,7 @@ func TestNoCommandOfTheStepSeesATokenOfTheProcess(t *testing.T) { } } // The runner main wires: what it hands a command is exactly what the step gave. - ran := forgekey.ExecRunnerClean(10*time.Second)(context.Background(), append(m.baseEnv(), "ONLY=this"), "env") + ran := dforgekey.ExecRunnerClean(10*time.Second)(context.Background(), append(m.baseEnv(), "ONLY=this"), "env") if ran.Status != 0 || strings.Contains(ran.Stdout, "hunter") || !strings.Contains(ran.Stdout, "ONLY=this") { t.Errorf("the clean runner printed %q", ran.Stdout) } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/forge_test.go b/modules/mesh-build-node/cmd/mesh-build-node/forge_test.go index 030e223d0..26f8dfabb 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/forge_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/forge_test.go @@ -14,6 +14,7 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -235,7 +236,7 @@ func TestTheKeeperWritesTheModulesEntryWithKindBuilder(t *testing.T) { if !ok || len(st.puts) != 1 { t.Fatalf("entries = %v, want one at ace.%s", st.puts, forgeModule) } - var e forgekey.KeyEntry + var e sshkeys.KeyEntry if err := json.Unmarshal(raw, &e); err != nil { t.Fatal(err) } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/generated_package_test.go b/modules/mesh-build-node/cmd/mesh-build-node/generated_package_test.go new file mode 100644 index 000000000..3c173d129 --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/generated_package_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "context" + "path/filepath" + "slices" + "testing" + + "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/seatgen" +) + +// F7 (ADR 0625 §5, ADR 0634 §8): a package the seat generator wrote, with its dependency-free lock and no script, is +// installed from that lock (npm ci) and packed (npm pack) with no build script run, as any package artifact is. +func TestAGeneratedSeatPackageIsInstalledFromItsLockAndPackedWithoutABuild(t *testing.T) { + m, f, work := newMachine(t) + seat := `{"format":1,"seat":"one-store","scope":"mesh","grain":"whole","domain":"Module","decision":"ADR 1","verbs":[{"name":"query","input":{"type":"object"},"answer":{"type":"object"}}]}` + src := t.TempDir() + write(t, filepath.Join(src, "seats", "one-store", "seat.json"), seat, 0o644) + g, err := seatgen.Load(src, "example.org/c/go", "@example/c") + if err != nil { + t.Fatal(err) + } + files, err := seatgen.Generate(g) + if err != nil { + t.Fatal(err) + } + dir := filepath.Join(work, "w1", "tree", "ts", "seats", "onestore") + for _, name := range []string{"package.json", "package-lock.json"} { + body, ok := files["ts/seats/onestore/"+name] + if !ok { + t.Fatalf("the generator wrote no %s for the seat's package", name) + } + write(t, filepath.Join(dir, name), string(body), 0o644) + } + f.effect = func(c call) { + if c.name == "npm" && len(c.args) > 0 && c.args[0] == "pack" { + dest := c.args[slices.Index(c.args, "--pack-destination")+1] + write(t, filepath.Join(dest, "example-c-one-store-0.1.0.tgz"), "tgz", 0o644) + } + } + ctx := context.Background() + if err := m.installDev(ctx, work, dir, "ts/seats/onestore"); err != nil { + t.Fatal(err) + } + p, err := m.buildAndPack(ctx, work, dir, "ts/seats/onestore", false, "") + if err != nil { + t.Fatal(err) + } + if p.Name != "@example/c-one-store" || p.Version != "0.1.0" { + t.Errorf("packed %s@%s", p.Name, p.Version) + } + var ran []string + for _, c := range f.calls { + if c.name == "npm" { + ran = append(ran, c.args[0]) + } + } + if !slices.Equal(ran, []string{"ci", "pack"}) { + t.Errorf("npm ran %v, want ci then pack and no build script", ran) + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/isolate.go b/modules/mesh-build-node/cmd/mesh-build-node/isolate.go index f4fceff44..86d012bff 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/isolate.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/isolate.go @@ -2,7 +2,7 @@ package main // The run steps shut off (novox/hq ADR 0448; issue 1309, lead decision L2 of R4e): the type-check, the package's test // script, tsc and esbuild run the change's code, so each runs through mesh-sdk's buildstep.Isolated, in bubblewrap: no -// network, none of the account (its home, its ssh key, its registry secret, its bus login, the runtime directory), the +// network, none of the account (its home, its ssh key, its bus login, the runtime directory), the // workspace read-write, the toolchain read-only, the shared npm cache read-only beneath a throwaway writable layer, or // read-write for a trunk build alone (publish). The fetch step (git, npm ci of the module's own packages) holds the key // and the network and runs none of the change's code, so it runs as before. @@ -18,6 +18,7 @@ import ( "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -53,7 +54,7 @@ func (m *Module) stepRunner(root string, write bool) (forgekey.Runner, error) { func ProbeBubblewrap(ctx context.Context) (string, error) { ctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() - r := forgekey.ExecRunnerClean(0)(ctx, []string{"PATH=" + defaultPath}, buildstep.Bubblewrap, "--version") + r := dforgekey.ExecRunnerClean(0)(ctx, []string{"PATH=" + defaultPath}, buildstep.Bubblewrap, "--version") if r.Status != 0 || r.Err != "" { return "", fmt.Errorf("`%s --version` failed: %s", buildstep.Bubblewrap, firstLine(r.Err+"\n"+r.Stderr)) } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/keys.go b/modules/mesh-build-node/cmd/mesh-build-node/keys.go index 9c92386d7..fa4aaa1a1 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/keys.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/keys.go @@ -13,14 +13,16 @@ import ( "strings" "time" + "git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/forgekey" ) // keeperFor is this module's keeper: go/forgekey's, of kind builder, for account in home on node, whose entry lies // at . of the keys state. -func keeperFor(home, node, account string, run forgekey.Runner, state forgekey.KeyPutter) *forgekey.KeyKeeper { - return &forgekey.KeyKeeper{Home: home, Node: node, Account: account, Kind: forgekey.KindBuilder, Run: run, State: state, Now: time.Now} +func keeperFor(home, node, account string, run forgekey.Runner, state sshkeys.KeyPutter) *sshkeys.KeyKeeper { + return &sshkeys.KeyKeeper{Home: home, Node: node, Account: account, Kind: dforgekey.KindBuilder, Run: run, State: state, Now: time.Now} } // startKeeper runs the keeper when the runtime launched this process for a node: outside a launch — a build, a diff --git a/modules/mesh-build-node/cmd/mesh-build-node/kit_test.go b/modules/mesh-build-node/cmd/mesh-build-node/kit_test.go index f18771389..d3799ea42 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/kit_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/kit_test.go @@ -6,7 +6,8 @@ import ( "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/conformance" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" + buildrun "git.novox.be/novox/mesh-delivery/sdk/buildrun" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -25,7 +26,7 @@ func TestTheTestStepKeepsTheFailureContract(t *testing.T) { // case: a step a previous process left running is ended at once and said, whatever its age, and a step of this process // is kept. func TestTheStartSweepEndsWhatAPreviousProcessLeft(t *testing.T) { - conformance.LeftRunning(t, func(ctx context.Context, scan sdkstep.Scan, node string, before time.Time, logf func(string, ...any)) { + conformance.LeftRunning(t, func(ctx context.Context, scan buildrun.Scan, node string, before time.Time, logf func(string, ...any)) { (&Module{Node: node}).endLeftRunning(ctx, scan, before, logf) }) } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/main.go b/modules/mesh-build-node/cmd/mesh-build-node/main.go index 8f2e338d9..e5babf16d 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/main.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/main.go @@ -15,9 +15,10 @@ import ( "strings" "time" + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildnode" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" + dforgekey "git.novox.be/novox/mesh-delivery/sdk/forgekey" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/forgekey" ) // CacheDir is the cache directory, outside the account's home (ADR 0466 §1; module.json resource `cache`). @@ -36,26 +37,25 @@ func main() { } paths := Paths{Home: home, Cache: CacheDir} registry := func() (Registry, error) { - return ReadRegistry(filepath.Join(home, "package-registry.json"), filepath.Join(home, "package-registry.secret")) + return ReadRegistry(filepath.Join(home, "package-registry.json")) } m := &Module{ Node: strings.TrimSpace(os.Getenv("MESH_NODE")), Reload: func() (Recipe, error) { return ReadRecipe(paths.LockPath()) }, Probe: ProbeNode, - Toolchain: &Toolchain{Paths: paths, Run: forgekey.ExecRunnerClean(0), Registry: registry}, - Toolchains: stdio.State(delivery.ToolchainsState).Put, + Toolchain: &Toolchain{Paths: paths, Run: dforgekey.ExecRunnerClean(0), Registry: registry}, + Toolchains: stdio.State(seat.StateToolchains).Put, Emit: func(t string, b any) error { return stdio.Emit(t, b) }, - Steps: stdio.State(delivery.StepsState).Put, - ReadSteps: readOf(stdio.State(delivery.StepsState)), - Pauses: stdio.State(delivery.PausesState).Put, - ReadPauses: readOf(stdio.State(delivery.PausesState)), - ReadToolchainsState: readOf(stdio.State(delivery.ToolchainsState)), + Steps: stdio.State(seat.StateSteps).Put, + ReadSteps: readOf(stdio.State(seat.StateSteps)), + Pauses: stdio.State(sdkstep.PausesState).Put, + ReadPauses: readOf(stdio.State(sdkstep.PausesState)), + ReadToolchainsState: readOf(stdio.State(seat.StateToolchains)), Work: WorkDir, - Run: forgekey.ExecRunnerClean(0), + Run: dforgekey.ExecRunnerClean(0), Ctx: ctx, Say: logf, Bubblewrap: ProbeBubblewrap, - RegistryFile: filepath.Join(home, "registry"), } m.Floor = floorOf(m.Node) m.Settings = filepath.Join(home, "settings.json") diff --git a/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go b/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go index 3da4725cd..fbac8729f 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go @@ -46,6 +46,14 @@ func resource(t *testing.T, m catalogue.Manifest, id string) map[string]any { // home at mode 0700, requires user-namespaces and declares the four packages and carries toolchain/package-lock.json. func TestManifestDeclaresTheAccountTheCapabilityAndThePackages(t *testing.T) { m := readManifest(t) + // The key keeper is ssh-client's published package, taken by its series (ADR 0634 §1). + // The builder's step, answer and key forms are read through its published package, taken by its series (ADR 0634 §1). + if m.Packages["builds"] != "0.1" { + t.Errorf("packages %v: want builds of series 0.1", m.Packages) + } + if m.Packages["sshkeys"] != "0.1" { + t.Errorf("packages %v: want sshkeys of series 0.1", m.Packages) + } if m.Module != "mesh-build-node" || m.RunsAs != "mesh-build-node" { t.Errorf("module %q runs as %q, want mesh-build-node as itself", m.Module, m.RunsAs) } @@ -213,37 +221,45 @@ func TestTheModuleDeclaresBubblewrapAndTheCapabilityItNeeds(t *testing.T) { } } -// 1587-S5 (ADR 0498 §2): the module requires the artifact store and is told its address in a file of its home, which no -// run step is given (a run step has no network either, ADR 0448). -func TestTheModuleRequiresTheArtifactStoreAndIsToldItsAddress(t *testing.T) { +// CAT-BN3 (design 51 *Publishing*, ADR 0346): the module publishes only through the registry seats' verbs, which it +// invokes; it requires no store, places no store's address, and holds no credential for the package registry: reads +// are anonymous (the provision's form none), so it contributes nothing to it and is sealed no secret. +func TestTheModuleInvokesTheRegistrySeatsAndHoldsNoWriteCredential(t *testing.T) { m := readManifest(t) - if !slices.Contains(m.Requires, "artifact-store") { - t.Errorf("requires %v lack artifact-store", m.Requires) + for _, seat := range []string{"oci-artifact-registry", "npm-package-registry"} { + for _, verb := range []string{"publish", "publish-part"} { + if !slices.Contains(m.Invokes, "seat:"+seat+"."+verb) { + t.Errorf("invokes %v lack the seat %s's %s", m.Invokes, seat, verb) + } + } } - // Decoding leaves the template (files/registry) unread in the resource, so its text is read here. - r := resource(t, m, "registry") - content, err := os.ReadFile(filepath.Join(moduleDir, "files", "registry")) - if err != nil || r["type"] != "file" || r["path"] != "${dir:state}/registry" || r["template"] != "files/registry" || - !strings.Contains(string(content), "${seat:mesh-artifact-store:reach}") { - t.Errorf("the registry resource is %v (template %q, %v)", r, content, err) + if slices.Contains(m.Requires, "artifact-store") { + t.Errorf("requires %v still name artifact-store", m.Requires) + } + for _, r := range m.Resources { + if r["id"] == "registry" { + t.Errorf("the store's address is still placed: %v", r) + } + } + if _, err := os.Stat(filepath.Join(moduleDir, "files", "registry")); err == nil { + t.Error("files/registry is still shipped") } -} - -// 1587-S17 (ADR 0498 §5): the module publishes a trunk package itself, so its contribution to the package registry asks -// for write access; the credential stays with the module's own process (a run step has neither the home nor a network). -func TestTheModuleContributesWriteAccessToThePackageRegistry(t *testing.T) { raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) if err != nil { t.Fatal(err) } var mj struct { - Contributes map[string]map[string]any `json:"contributes"` + Contributes map[string]any `json:"contributes"` + Secrets map[string]string `json:"secrets"` } if err := json.Unmarshal(raw, &mj); err != nil { t.Fatal(err) } - if got := mj.Contributes["npm-package-registry"]["access"]; got != "write" { - t.Errorf("npm-package-registry access = %v, want write", got) + if _, ok := mj.Contributes["npm-package-registry"]; ok { + t.Errorf("contributes %v still ask the package registry for access", mj.Contributes) + } + if _, ok := mj.Secrets["npm-package-registry"]; ok { + t.Errorf("secrets %v still seal the package registry's credential", mj.Secrets) } } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/module.go b/modules/mesh-build-node/cmd/mesh-build-node/module.go index 1932be8c8..03f3f0d12 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/module.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/module.go @@ -9,8 +9,8 @@ import ( "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" + buildrun "git.novox.be/novox/mesh-delivery/sdk/buildrun" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/forgekey" "git.novox.be/novox/mesh-sdk/go/health" ) @@ -57,10 +57,9 @@ type Module struct { Say func(format string, a ...any) // HTTP reads and publishes to the package registry; a client of its own when nil. A test serves its own. HTTP *http.Client - // RegistryFile holds the artifact store's address (host:port), placed by the manifest; Push puts a blob there - // (buildstep.PushBlob when nil; a test's fake store). - RegistryFile string - Push func(ctx context.Context, registry, repository, digest string, body []byte) error + // Call asks a registry seat's verb over the bus, the publish of a done build (publish.go): stdio.Ask when nil; a + // test's fake holder. + Call buildstep.SeatCall // Bubblewrap reads the installed bubblewrap's version (ProbeBubblewrap): a step is refused, and the module is // unhealthy, while it is missing or older than BubblewrapLeast. Nil probes nothing. Bubblewrap func(ctx context.Context) (string, error) @@ -140,7 +139,7 @@ func (m *Module) Start(ctx context.Context) { m.Say("the toolchain: %v", err) } m.CleanWorkspaces(m.now()) - sdkstep.EndStaleRound(m.stepScan(), m.Node, m.Say) // asks past the step bound, on the module's own round (issue 1890) + buildrun.EndStaleRound(m.stepScan(), m.Node, m.Say) // asks past the step bound, on the module's own round (issue 1890) select { case <-ctx.Done(): return @@ -191,8 +190,8 @@ func (m *Module) Long(tool string, run func(ctx context.Context, args map[string if err != nil { return nil, err } - key, _ := delivery.StepKey(m.Node, id) - return map[string]any{"step": id, "key": key, "state": delivery.StepRunning}, nil + key, _ := sdkstep.StepKey(m.Node, id) + return map[string]any{"step": id, "key": key, "state": sdkstep.StepRunning}, nil } } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/module_test.go b/modules/mesh-build-node/cmd/mesh-build-node/module_test.go index e5fc60f33..cbae60e38 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/module_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/module_test.go @@ -11,24 +11,24 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/health" ) // keptStates stands in for the toolchains and the steps states. type keptStates struct { mu sync.Mutex - toolchains map[string]delivery.Toolchain - steps map[string][]delivery.Step + toolchains map[string]sdkstep.Toolchain + steps map[string][]sdkstep.Step ended chan string } func newStates() *keptStates { - return &keptStates{toolchains: map[string]delivery.Toolchain{}, steps: map[string][]delivery.Step{}, ended: make(chan string, 16)} + return &keptStates{toolchains: map[string]sdkstep.Toolchain{}, steps: map[string][]sdkstep.Step{}, ended: make(chan string, 16)} } func (s *keptStates) putToolchain(key string, value any) (uint64, error) { - tc, err := delivery.DecodeToolchain(value.(json.RawMessage)) + tc, err := sdkstep.DecodeToolchain(value.(json.RawMessage)) if err != nil { return 0, err } @@ -39,27 +39,27 @@ func (s *keptStates) putToolchain(key string, value any) (uint64, error) { } func (s *keptStates) putStep(key string, value any) (uint64, error) { - st, err := delivery.DecodeStep(value.(json.RawMessage)) + st, err := sdkstep.DecodeStep(value.(json.RawMessage)) if err != nil { return 0, err } s.mu.Lock() s.steps[key] = append(s.steps[key], st) s.mu.Unlock() - if st.State != delivery.StepRunning { + if st.State != sdkstep.StepRunning { s.ended <- key } return 1, nil } -func (s *keptStates) toolchain(key string) (delivery.Toolchain, bool) { +func (s *keptStates) toolchain(key string) (sdkstep.Toolchain, bool) { s.mu.Lock() defer s.mu.Unlock() tc, ok := s.toolchains[key] return tc, ok } -func (s *keptStates) waitEnd(t *testing.T) delivery.Step { +func (s *keptStates) waitEnd(t *testing.T) sdkstep.Step { t.Helper() select { case key := <-s.ended: @@ -69,7 +69,7 @@ func (s *keptStates) waitEnd(t *testing.T) delivery.Step { return v[len(v)-1] case <-time.After(3 * time.Second): t.Fatal("the step never ended on its key") - return delivery.Step{} + return sdkstep.Step{} } } @@ -87,7 +87,7 @@ func TestStartWritesTheToolchainAndHealthIsHealthy(t *testing.T) { ctx, stop := context.WithCancel(context.Background()) defer stop() go m.Start(ctx) - var tc delivery.Toolchain + var tc sdkstep.Toolchain for deadline := time.Now().Add(3 * time.Second); time.Now().Before(deadline); time.Sleep(5 * time.Millisecond) { if got, ok := states.toolchain("ace.typescript"); ok { tc = got @@ -130,7 +130,7 @@ func TestANodeThatCannotBeRunRefusesEveryStepAndHealthIsUnhealthy(t *testing.T) t.Fatalf("%s answered no step id: %v", tool.Name, out) } st := states.waitEnd(t) - if st.State != delivery.StepRefused { + if st.State != sdkstep.StepRefused { t.Errorf("%s: the step ended %s, want refused", tool.Name, st.State) } reason = st.Reason diff --git a/modules/mesh-build-node/cmd/mesh-build-node/package.go b/modules/mesh-build-node/cmd/mesh-build-node/package.go index 713d76c6c..bb2f51bcc 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/package.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/package.go @@ -25,12 +25,13 @@ import ( "os" "path" "path/filepath" + "regexp" "sort" "strings" "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" + builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -97,9 +98,8 @@ func (m *Module) installDev(ctx context.Context, root, dir, rel string) error { return writeShims(filepath.Join(dir, "node_modules"), shimDir(root, rel)) } -// npmrcIn writes the npm configuration for the mesh's registry, with its credential, for one command of this process; -// the caller removes it. It lies in this process's temporary directory, never in a workspace: a run step is given the -// workspace and a /tmp of its own, so no step's mounts hold the credential (ADR 0498 §5, ADR 0500 §4). +// npmrcIn writes the npm configuration for the mesh's registry, read anonymously, for one command of this process; the +// caller removes it. It lies in this process's temporary directory, never in a workspace (ADR 0498 §5, ADR 0500 §4). func (m *Module) npmrcIn(root string) (string, error) { if m.Toolchain == nil || m.Toolchain.Registry == nil { return "", fmt.Errorf("the module has no package registry") @@ -188,7 +188,14 @@ func writeShims(nm, into string) error { // buildAndPack builds the package in dir with its own build script, if it has one, and packs it with npm pack, both // shut off (write: a trunk build's, which alone writes the shared cache). The packed file is read and removed: it never // stays on the node. -func (m *Module) buildAndPack(ctx context.Context, root, dir, rel string, write bool) (packed, error) { +func (m *Module) buildAndPack(ctx context.Context, root, dir, rel string, write bool, version string) (packed, error) { + if version != "" { + restore, err := setPackageVersion(dir, version) + if err != nil { + return packed{}, err + } + defer restore() + } p, err := readPackageJSON(dir) if err != nil { return packed{}, err @@ -219,8 +226,15 @@ func (m *Module) buildAndPack(ctx context.Context, root, dir, rel string, write } // groupPackage is the fetch's `package` argument: the group's package member (ADR 0500 §5), which the fetch packs and -// serves to its TypeScript members in place of the registry's version. -type groupPackage struct{ Repository, Commit, Ref string } +// serves to its TypeScript members in place of the registry's version, or, by Name and Version, the group's tagged +// version of the SDK, which the fetch places from the registry in each TypeScript member, whatever version its lock +// names (novox/hq ADR 0607 §3, §5; design 51 *Tagged versions*): nothing is packed then. The packed member is the old +// form, kept because the running checker hands it until the checker that hands the tagged version is delivered; its +// removal is a subtask of the milestone after that delivery (issue 1966). +type groupPackage struct{ Repository, Commit, Ref, Name, Version string } + +// tagged says whether the argument names a tagged version to take from the registry, not a member to pack. +func (g *groupPackage) tagged() bool { return g != nil && g.Version != "" } func groupPackageArg(v any) (*groupPackage, error) { if v == nil { @@ -228,9 +242,20 @@ func groupPackageArg(v any) (*groupPackage, error) { } o, ok := v.(map[string]any) if !ok { - return nil, fmt.Errorf("package is {repository, commit, ref}") + return nil, fmt.Errorf("package is {repository, commit, ref} or {name, version}") } g := &groupPackage{} + g.Name, _ = o["name"].(string) + g.Version, _ = o["version"].(string) + if g.Name != "" || g.Version != "" { + if g.Name != sdkPackage { + return nil, fmt.Errorf("package: %q is not %s, the one package the fetch places in a member", g.Name, sdkPackage) + } + if !npmVersionOK.MatchString(g.Version) { + return nil, fmt.Errorf("package: %q is not a version (0.1.15-feat-x.2)", g.Version) + } + return g, nil + } g.Repository, _ = o["repository"].(string) g.Commit, _ = o["commit"].(string) g.Ref, _ = o["ref"].(string) @@ -246,6 +271,9 @@ func groupPackageArg(v any) (*groupPackage, error) { return g, nil } +// npmVersionOK is a semantic version as npm names one, with an optional pre-release (a tagged version's). +var npmVersionOK = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`) + // packGroup fetches the group's package member into the workspace, installs its dependencies, builds and packs it, // publishing nothing (ADR 0500 §4). func (m *Module) packGroup(ctx context.Context, root string, run forgekey.Runner, g *groupPackage) (packed, error) { @@ -259,7 +287,7 @@ func (m *Module) packGroup(ctx context.Context, root string, run forgekey.Runner if err := m.installDev(ctx, root, dir, "package"); err != nil { return packed{}, err } - return m.buildAndPack(ctx, root, dir, "package", false) + return m.buildAndPack(ctx, root, dir, "package", false, "") } // sdkLocked is what a module's lock names of the SDK: its version, its integrity and where it was resolved from; ok is @@ -290,21 +318,45 @@ func sdkLocked(dir string) (version, integrity string, ok bool, err error) { // placeSDK places in the module's node_modules the SDK version its lock names (the lead's decision L10): the group's // packed package when it is that version (its integrity must be the lock's), else the registry's; a version neither -// holds is a wait (`waits for:`) when no group package was given, and a failure naming both when one was. -func (m *Module) placeSDK(ctx context.Context, dir, rel string, group *packed) error { +// holds is a wait (`waits for:`) when no group package was given, and a failure naming both when one was. Given the +// group's tagged version (tagged, ADR 0607 §3), that version is placed from the registry whatever the lock names, its +// integrity the lock's when the lock names it, else the registry's own; one the registry lacks fails naming it. +func (m *Module) placeSDK(ctx context.Context, dir, rel string, group *packed, tagged *groupPackage) error { version, integrity, ok, err := sdkLocked(dir) if err != nil || !ok { return err } - want := sdkPackage + "@" + version var tgz []byte switch { + case tagged.tagged(): + if version != tagged.Version { + if m.Say != nil { + m.Say("%s: its lock names %s@%s; the group's tagged version %s is placed (ADR 0607 §3)", rel, sdkPackage, version, tagged.Version) + } + version, integrity = tagged.Version, "" + } + want := sdkPackage + "@" + version + got, registered, held, err := m.registryPackageChecked(ctx, sdkPackage, version) + if err != nil { + return err + } + if !held { + return fmt.Errorf("the registry holds no %s, the group's tagged version, so %s cannot be built against it", want, rel) + } + if integrity == "" { + integrity = registered + } + if integrity != "" && integrityOf(got) != integrity { + return fmt.Errorf("the registry's %s is %s, and %s names %s", want, integrityOf(got), rel, integrity) + } + tgz = got case group != nil && group.Name == sdkPackage && group.Version == version: if integrity != "" && integrity != group.Integrity { - return fmt.Errorf("the lock of %s names %s with the integrity %s, and the group's package packs to %s", rel, want, integrity, group.Integrity) + return fmt.Errorf("the lock of %s names %s with the integrity %s, and the group's package packs to %s", rel, sdkPackage+"@"+version, integrity, group.Integrity) } tgz = group.tgz default: + want := sdkPackage + "@" + version got, held, err := m.registryPackage(ctx, sdkPackage, version) if err != nil { return err @@ -313,7 +365,7 @@ func (m *Module) placeSDK(ctx context.Context, dir, rel string, group *packed) e if group != nil { return fmt.Errorf("the lock of %s names %s, which neither the registry nor the group (%s@%s) holds", rel, want, group.Name, group.Version) } - return fmt.Errorf("%s%s", delivery.WaitsFor, want) + return fmt.Errorf("%s%s", builds.WaitsFor, want) } if integrity != "" && integrityOf(got) != integrity { return fmt.Errorf("the registry's %s is %s, and the lock of %s names %s", want, integrityOf(got), rel, integrity) @@ -327,6 +379,16 @@ func (m *Module) placeSDK(ctx context.Context, dir, rel string, group *packed) e return unpackPackage(tgz, into) } +// registryPackageChecked is registryPackage with the integrity the registry's document names of name@version. +func (m *Module) registryPackageChecked(ctx context.Context, name, version string) (tgz []byte, integrity string, held bool, err error) { + _, integrity, held, err = m.registryVersion(ctx, name, version) + if err != nil || !held { + return nil, "", held, err + } + tgz, held, err = m.registryPackage(ctx, name, version) + return tgz, integrity, held, err +} + // httpClient is the client of the registry's reads and publishes: HTTP when a test set it. func (m *Module) httpClient() *http.Client { if m.HTTP != nil { @@ -356,7 +418,6 @@ func (m *Module) registryVersion(ctx context.Context, name, version string) (tar if err != nil { return "", "", false, err } - reg.authorize(req) resp, err := m.httpClient().Do(req) if err != nil { return "", "", false, fmt.Errorf("asking the registry for %s: %w", name, err) @@ -386,15 +447,10 @@ func (m *Module) registryPackage(ctx context.Context, name, version string) ([]b if err != nil || !held { return nil, held, err } - reg, err := m.Toolchain.Registry() - if err != nil { - return nil, false, err - } req, err := http.NewRequestWithContext(ctx, http.MethodGet, tarball, nil) if err != nil { return nil, false, err } - reg.authorize(req) resp, err := m.httpClient().Do(req) if err != nil { return nil, false, fmt.Errorf("downloading %s@%s: %w", name, version, err) @@ -465,65 +521,61 @@ func fetchCommit(ctx context.Context, run forgekey.Runner, dir, remote, commit, return buildstep.FetchCommit(ctx, run, dir, remote, commit, ref, m.FetchOptions...) } -// buildPackage builds and packs a package artifact (ADR 0498 §5): its digest is the packed file's. Only a trunk build -// (publish) publishes it, from this module's own process with the registry's write credential, which no run step is -// given: a version the registry lacks is published; the same packed file already there is done without a publish; -// another packed file under that version fails `version taken:`. The reference is @, answered only when -// the registry holds this build's file; a candidate answers its digest alone. dir is the package's directory, at its -// place in the module's. -func (m *Module) buildPackage(ctx context.Context, workspace, module, root, dir, at string, a artifact, publish bool) (any, error) { +// buildPackage builds and packs a package artifact (ADR 0498 §5): its digest is the packed file's. A trunk build +// (publish) the builder gave a version packs it under that version and publishes it through npm-package-registry +// (design 51 *Publishing*, CAT-BN3), from this module's own process, which holds no write credential: the holder asks +// the builder, answers held for the same file and refuses another under that version. The reference is +// @, answered only when the holder took it; a candidate, or a trunk build not numbered, answers its +// digest alone. dir is the package's directory, at its place in the module's. +func (m *Module) buildPackage(ctx context.Context, workspace, module, root, dir, at string, a artifact, publish bool, version, seat string) (any, error) { // rel is the package's directory in the workspace, as the fetch installed it (the shims lie under it). rel := path.Join(module, at) if module == "" { rel = path.Join(".", at) } - p, err := m.buildAndPack(ctx, root, dir, rel, publish) + if !publish { + version = "" + } + if seat == "" { + seat = buildstep.NpmSeat + } + if version != "" && seat != buildstep.NpmSeat { + return nil, fmt.Errorf("the package %s is published through %s, not %s: this module publishes npm packages", a.Name, buildstep.NpmSeat, seat) + } + p, err := m.buildAndPack(ctx, root, dir, rel, publish, version) if err != nil { return nil, err } res := BuildResult{Workspace: workspace, Module: module, Artifact: a.Name, Digest: p.Digest, Bytes: len(p.tgz)} - if publish { - if res.Reference, err = m.publish(ctx, root, p); err != nil { - return nil, err - } + if version == "" { + return res, nil } + if err := m.publishOn(ctx, seat, p.Name, "package", p.Version, p.Digest, p.tgz); err != nil { + return nil, fmt.Errorf("%s%s: %v", builds.NotStored, a.Name, err) + } + res.Reference = p.Name + "@" + p.Version return res, nil } -// publish puts a packed package into the registry unless it holds that version, and answers @. -func (m *Module) publish(ctx context.Context, root string, p packed) (string, error) { - ref := p.Name + "@" + p.Version - _, integrity, held, err := m.registryVersion(ctx, p.Name, p.Version) +// setPackageVersion writes version as dir's package.json `version` (the builder's, design 51 *The versions*), so npm +// packs the package under it; restore puts the file back as it was. +func setPackageVersion(dir, version string) (restore func(), err error) { + path := filepath.Join(dir, "package.json") + raw, err := os.ReadFile(path) if err != nil { - return "", err + return nil, fmt.Errorf("the package's package.json: %w", err) } - if held { - if integrity == p.Integrity { - return ref, nil // this very file, published before: done without a publish - } - return "", fmt.Errorf("%s%s: the registry holds it as %s, and this build packs %s", delivery.VersionTaken, ref, integrity, p.Integrity) + var doc map[string]json.RawMessage + if err := json.Unmarshal(raw, &doc); err != nil { + return nil, fmt.Errorf("the package's package.json does not parse: %w", err) } - file, err := os.CreateTemp(root, "publish-*.tgz") + doc["version"], _ = json.Marshal(version) + out, err := json.MarshalIndent(doc, "", " ") if err != nil { - return "", err + return nil, err } - defer os.Remove(file.Name()) // the packed file leaves the node once its publish answered - if _, err := file.Write(p.tgz); err != nil { - file.Close() - return "", err + if err := os.WriteFile(path, append(out, '\n'), 0o644); err != nil { + return nil, err } - if err := file.Close(); err != nil { - return "", err - } - npmrc, err := m.npmrcIn(root) - if err != nil { - return "", err - } - defer os.Remove(npmrc) - env := append(m.baseEnv(), "npm_config_cache="+m.npmCache()) - r := m.Run(ctx, env, "npm", "publish", file.Name(), "--ignore-scripts", "--userconfig", npmrc) - if why := ranFailed(r); why != "" { - return "", fmt.Errorf("publishing %s: %s", ref, why) - } - return ref, nil + return func() { _ = os.WriteFile(path, raw, 0o644) }, nil } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/package_test.go b/modules/mesh-build-node/cmd/mesh-build-node/package_test.go index 58000bd88..7340d7f85 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/package_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/package_test.go @@ -14,7 +14,7 @@ import ( "sync/atomic" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" + builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" ) // tgzOf is a packed npm package holding package/package.json of name@version and one more file. @@ -98,18 +98,45 @@ func TestAMemberGetsTheSDKVersionItsLockNamesOrWaitsForIt(t *testing.T) { held := tgzOf(t, sdkPackage, "0.1.15") fakeRegistry(t, m, map[string][]byte{"0.1.15": held}) dir := memberLocking(t, "0.1.15", integrityOf(held)) - if err := m.placeSDK(context.Background(), dir, "modules/m", nil); err != nil || placedVersion(dir) != "0.1.15" { + if err := m.placeSDK(context.Background(), dir, "modules/m", nil, nil); err != nil || placedVersion(dir) != "0.1.15" { t.Errorf("the registry's 0.1.15: %v, placed %q", err, placedVersion(dir)) } dir = memberLocking(t, "0.1.16", "sha512-x") - err := m.placeSDK(context.Background(), dir, "modules/m", nil) - if err == nil || err.Error() != delivery.WaitsFor+"@novox/mesh-sdk@0.1.16" { - t.Errorf("a version the registry lacks answered %v, want %s@novox/mesh-sdk@0.1.16", err, delivery.WaitsFor) + err := m.placeSDK(context.Background(), dir, "modules/m", nil, nil) + if err == nil || err.Error() != builds.WaitsFor+"@novox/mesh-sdk@0.1.16" { + t.Errorf("a version the registry lacks answered %v, want %s@novox/mesh-sdk@0.1.16", err, builds.WaitsFor) } dir = memberLocking(t, "0.1.15", "sha512-another") - if err := m.placeSDK(context.Background(), dir, "modules/m", nil); err == nil || !strings.Contains(err.Error(), "sha512-another") { + if err := m.placeSDK(context.Background(), dir, "modules/m", nil, nil); err == nil || !strings.Contains(err.Error(), "sha512-another") { t.Errorf("a registry file of another integrity than the lock's answered %v", err) } + // ADR 0607 §3, §5 (design 51 *Tagged versions*): given the group's tagged version, the fetch places it from the + // registry whatever the lock names, against the lock's integrity when the lock names it, else the registry's; one the + // registry lacks fails naming it, no wait (the check waited for its published). + tagged := tgzOf(t, sdkPackage, "0.1.16-feat-x.1") + fakeRegistry(t, m, map[string][]byte{"0.1.15": held, "0.1.16-feat-x.1": tagged}) + group := &groupPackage{Name: sdkPackage, Version: "0.1.16-feat-x.1"} + for _, c := range []struct { + name, locks, integrity, fails string + }{ + {name: "a lock naming another version", locks: "0.1.15", integrity: integrityOf(held)}, + {name: "a lock naming the tagged version", locks: "0.1.16-feat-x.1", integrity: integrityOf(tagged)}, + {name: "a lock naming it with another integrity", locks: "0.1.16-feat-x.1", integrity: "sha512-other", fails: "sha512-other"}, + } { + dir = memberLocking(t, c.locks, c.integrity) + err := m.placeSDK(context.Background(), dir, "modules/m", nil, group) + switch { + case c.fails == "" && (err != nil || placedVersion(dir) != "0.1.16-feat-x.1"): + t.Errorf("%s: %v, placed %q, want the group's tagged version", c.name, err, placedVersion(dir)) + case c.fails != "" && (err == nil || !strings.Contains(err.Error(), c.fails)): + t.Errorf("%s: %v, want it to fail naming %s", c.name, err, c.fails) + } + } + dir = memberLocking(t, "0.1.15", integrityOf(held)) + missing := &groupPackage{Name: sdkPackage, Version: "0.1.16-feat-x.9"} + if err := m.placeSDK(context.Background(), dir, "modules/m", nil, missing); err == nil || !strings.Contains(err.Error(), "0.1.16-feat-x.9") || strings.HasPrefix(err.Error(), builds.WaitsFor) { + t.Errorf("a tagged version the registry lacks answered %v, want it named and no wait", err) + } } // ADR 0500 §5 (S3, S4): the group's packed package is served in place of the registry's when the member's lock names its @@ -121,24 +148,24 @@ func TestTheGroupsPackageIsServedToAMemberLockingItsVersion(t *testing.T) { tgz := tgzOf(t, sdkPackage, "0.1.15") group := &packed{Name: sdkPackage, Version: "0.1.15", Integrity: integrityOf(tgz), Digest: digestOf(tgz), tgz: tgz} dir := memberLocking(t, "0.1.15", group.Integrity) - if err := m.placeSDK(context.Background(), dir, "modules/m", group); err != nil || placedVersion(dir) != "0.1.15" || asked.Load() != 0 { + if err := m.placeSDK(context.Background(), dir, "modules/m", group, nil); err != nil || placedVersion(dir) != "0.1.15" || asked.Load() != 0 { t.Errorf("the group's own version: %v, placed %q, the registry asked %d times", err, placedVersion(dir), asked.Load()) } dir = memberLocking(t, "0.1.15", "sha512-other") - if err := m.placeSDK(context.Background(), dir, "modules/m", group); err == nil || !strings.Contains(err.Error(), "sha512-other") || !strings.Contains(err.Error(), group.Integrity) { + if err := m.placeSDK(context.Background(), dir, "modules/m", group, nil); err == nil || !strings.Contains(err.Error(), "sha512-other") || !strings.Contains(err.Error(), group.Integrity) { t.Errorf("another integrity answered %v, want both named", err) } dir = memberLocking(t, "0.1.14", integrityOf(tgzOf(t, sdkPackage, "0.1.14"))) - if err := m.placeSDK(context.Background(), dir, "modules/m", group); err != nil || placedVersion(dir) != "0.1.14" { + if err := m.placeSDK(context.Background(), dir, "modules/m", group, nil); err != nil || placedVersion(dir) != "0.1.14" { t.Errorf("a member locking the registry's 0.1.14 in a group: %v, placed %q", err, placedVersion(dir)) } dir = memberLocking(t, "0.1.13", "sha512-x") - if err := m.placeSDK(context.Background(), dir, "modules/m", group); err == nil || !strings.Contains(err.Error(), "0.1.13") || !strings.Contains(err.Error(), "0.1.15") || strings.HasPrefix(err.Error(), delivery.WaitsFor) { + if err := m.placeSDK(context.Background(), dir, "modules/m", group, nil); err == nil || !strings.Contains(err.Error(), "0.1.13") || !strings.Contains(err.Error(), "0.1.15") || strings.HasPrefix(err.Error(), builds.WaitsFor) { t.Errorf("a version neither holds answered %v, want both named and no wait", err) } before := asked.Load() dir = memberLocking(t, "", "") - if err := m.placeSDK(context.Background(), dir, "modules/m", group); err != nil || placedVersion(dir) != "" || asked.Load() != before { + if err := m.placeSDK(context.Background(), dir, "modules/m", group, nil); err != nil || placedVersion(dir) != "" || asked.Load() != before { t.Errorf("a lock naming no SDK: %v, placed %q, the registry asked", err, placedVersion(dir)) } } @@ -148,7 +175,8 @@ func TestTheGroupsPackageIsServedToAMemberLockingItsVersion(t *testing.T) { func TestTheGroupPackageArgumentIsRefusedWhenItIsNotOne(t *testing.T) { for _, bad := range []any{"novox/mesh-sdk", map[string]any{"repository": "novox/mesh-sdk", "commit": "main"}, map[string]any{"repository": "../x", "commit": strings.Repeat("a", 40)}, - map[string]any{"repository": "novox/mesh-sdk", "commit": strings.Repeat("a", 40), "ref": "refs/heads/main"}} { + map[string]any{"repository": "novox/mesh-sdk", "commit": strings.Repeat("a", 40), "ref": "refs/heads/main"}, + map[string]any{"name": "left-pad", "version": "1.0.0"}, map[string]any{"name": sdkPackage, "version": "latest"}} { if _, err := groupPackageArg(bad); err == nil { t.Errorf("%v was taken", bad) } @@ -157,4 +185,7 @@ func TestTheGroupPackageArgumentIsRefusedWhenItIsNotOne(t *testing.T) { if err != nil || g.Ref != "refs/pull/9/head" { t.Errorf("a pull request's head: %+v %v", g, err) } + if g, err := groupPackageArg(map[string]any{"name": sdkPackage, "version": "0.1.16-feat-x.1"}); err != nil || !g.tagged() { + t.Errorf("the group's tagged version: %+v %v", g, err) + } } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/publish.go b/modules/mesh-build-node/cmd/mesh-build-node/publish.go new file mode 100644 index 000000000..bb3b1ceca --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/publish.go @@ -0,0 +1,29 @@ +package main + +// The publish of a done build's file (novox/hq design 51 *Publishing*, ADR 0611; CAT-BN3): from this module's own +// process, after the build step ended, through a registry seat's verbs publish and publish-part, which the manifest +// `invokes`, with the build modules' one sender (buildstep.PublishOne): a bundle's archive on oci-artifact-registry, a +// trunk package on npm-package-registry under the version the builder gave it. No build step reaches a store, and this +// module holds no write credential: the holder checks the build with the builder and says published or refused. + +import ( + "context" + + stdio "git.novox.be/novox/mesh-sdk/go" + + "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" +) + +// publishOn publishes body, of digest, as name (at version, when it has one) of kind on seat: through Call when a +// test set it, else the runtime's request over the bus (stdio.Ask). +func (m *Module) publishOn(ctx context.Context, seat, name, kind, version, digest string, body []byte) error { + call := m.Call + if call == nil { + call = stdio.Ask + } + q := kind + if version != "" { + q = "v" + version + } + return buildstep.PublishOne(ctx, call, seat, name, kind, version, digest, buildstep.TransferName(name, q, digest), body) +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/publish_integration_test.go b/modules/mesh-build-node/cmd/mesh-build-node/publish_integration_test.go index c8420b882..6f7d57106 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/publish_integration_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/publish_integration_test.go @@ -4,82 +4,18 @@ package main import ( "context" - "encoding/base64" - "encoding/json" - "io" - "net/http" - "net/http/httptest" + "os" "os/exec" "path/filepath" "strings" - "sync" "testing" "git.novox.be/novox/mesh-sdk/go/forgekey" ) -// npmRegistry is a registry of the test's own speaking npm's publish protocol: a PUT of the package's document with the -// packed file attached, then GET of the document and the file; it asks for the account's credential. -type npmRegistry struct { - mu sync.Mutex - versions map[string]map[string]any - files map[string][]byte - publishes int -} - -func (r *npmRegistry) serve(t *testing.T) *httptest.Server { - var srv *httptest.Server - srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, q *http.Request) { - r.mu.Lock() - defer r.mu.Unlock() - if u, p, ok := q.BasicAuth(); !ok || u != "builder" || p != "the write credential" { - w.WriteHeader(http.StatusUnauthorized) - return - } - doc := strings.TrimPrefix(q.URL.EscapedPath(), "/npm/") - switch { - case q.Method == http.MethodPut && (doc == "@novox%2fmesh-sdk" || doc == "@novox%2Fmesh-sdk"): - var body struct { - Versions map[string]map[string]any `json:"versions"` - Attachments map[string]struct { - Data string `json:"data"` - } `json:"_attachments"` - } - raw, _ := io.ReadAll(q.Body) - if err := json.Unmarshal(raw, &body); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - for v, meta := range body.Versions { - for name, a := range body.Attachments { - data, _ := base64.StdEncoding.DecodeString(a.Data) - r.files[name] = data - meta["dist"] = map[string]any{"tarball": srv.URL + "/files/" + name, "integrity": integrityOf(data)} - } - r.versions[v] = meta - } - r.publishes++ - w.WriteHeader(http.StatusCreated) - w.Write([]byte(`{"ok":true}`)) - case q.Method == http.MethodGet && (doc == "@novox%2fmesh-sdk" || doc == "@novox%2Fmesh-sdk"): - if len(r.versions) == 0 { - http.NotFound(w, q) - return - } - json.NewEncoder(w).Encode(map[string]any{"name": "@novox/mesh-sdk", "versions": r.versions}) - case strings.HasPrefix(q.URL.Path, "/files/"): - w.Write(r.files[strings.TrimPrefix(q.URL.Path, "/files/")]) - default: - http.NotFound(w, q) - } - })) - t.Cleanup(srv.Close) - return srv -} - -// 1587-S20 (ADR 0498 §5): the real npm publishes a trunk build's package to a registry speaking npm's protocol, with -// the module's credential, once: a second trunk build of the same package finds its file there and publishes nothing; -// a candidate publishes nothing at all. +// 1587-S20 (ADR 0498 §5), CAT-BN3 (design 51 *Publishing*): the real npm packs a trunk build's package under the +// version the builder gave it, and the module publishes that packed file through npm-package-registry's verbs, with no +// credential and no npm publish; the workspace's package.json is put back. A candidate publishes nothing at all. func TestARealTrunkPackageIsPublishedOnce(t *testing.T) { needBubblewrap(t) for _, tool := range []string{"node", "npm"} { @@ -89,34 +25,39 @@ func TestARealTrunkPackageIsPublishedOnce(t *testing.T) { } m, _, work := newMachine(t) m.Run = forgekey.ExecRunner - reg := &npmRegistry{versions: map[string]map[string]any{}, files: map[string][]byte{}} - srv := reg.serve(t) - m.Toolchain.Registry = func() (Registry, error) { - return Registry{URL: srv.URL + "/npm/", Username: "builder", Password: "the write credential"}, nil - } - m.HTTP = srv.Client() + store := withStore(t, m) files := map[string]string{} for n, c := range sdkFixture { files[n] = c } workspaceWith(t, work, files) dir := filepath.Join(work, "w1", "tree") + before, _ := os.ReadFile(filepath.Join(dir, "package.json")) if err := m.installDev(context.Background(), filepath.Join(work, "w1"), dir, "."); err != nil { t.Fatal(err) } args := map[string]any{"workspace": "w1", "module": ".", "artifact": "lib"} - if out, err := m.build(context.Background(), args); err != nil || out.(BuildResult).Reference != "" || reg.publishes != 0 { - t.Fatalf("a candidate: %+v %v, %d publishes", out, err, reg.publishes) + if out, err := m.build(context.Background(), args); err != nil || out.(BuildResult).Reference != "" || len(store.published) != 0 { + t.Fatalf("a candidate: %+v %v, %d published", out, err, len(store.published)) } - args["publish"] = true + args["publish"], args["version"], args["seat"] = true, "9.9.10", "npm-package-registry" out, err := m.build(context.Background(), args) - if err != nil || out.(BuildResult).Reference != "@novox/mesh-sdk@9.9.9" || reg.publishes != 1 { - t.Fatalf("a trunk build: %+v %v, %d publishes", out, err, reg.publishes) + if err != nil || out.(BuildResult).Reference != "@novox/mesh-sdk@9.9.10" || len(store.published) != 1 { + t.Fatalf("a trunk build: %+v %v, %d published", out, err, len(store.published)) } - if got := digestOf(reg.files["@novox/mesh-sdk-9.9.9.tgz"]); got != out.(BuildResult).Digest { - t.Errorf("the registry holds %s, the build packed %s (files %d)", got, out.(BuildResult).Digest, len(reg.files)) + p := store.published[0] + tgz := store.blobs[p.Name+"@"+p.Digest] + if p.Name != "@novox/mesh-sdk" || p.Version != "9.9.10" || p.Kind != "package" || digestOf(tgz) != out.(BuildResult).Digest { + t.Errorf("published %+v (%d bytes), the build packed %s", p, len(tgz), out.(BuildResult).Digest) } - if again, err := m.build(context.Background(), args); err != nil || again.(BuildResult).Reference != "@novox/mesh-sdk@9.9.9" || reg.publishes != 1 { - t.Errorf("the same package again: %+v %v, %d publishes", again, err, reg.publishes) + into := t.TempDir() + if err := unpackPackage(tgz, into); err != nil { + t.Fatal(err) + } + if raw, _ := os.ReadFile(filepath.Join(into, "package.json")); !strings.Contains(string(raw), `"version": "9.9.10"`) { + t.Errorf("the packed package.json is %s, want the builder's version", raw) + } + if after, _ := os.ReadFile(filepath.Join(dir, "package.json")); string(after) != string(before) { + t.Errorf("the workspace's package.json was not put back: %s", after) } } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/publish_test.go b/modules/mesh-build-node/cmd/mesh-build-node/publish_test.go index e1ab72cb1..43e426a0a 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/publish_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/publish_test.go @@ -2,13 +2,13 @@ package main import ( "context" + "errors" "os" "path/filepath" - "slices" "strings" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" + builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" ) const packageManifest = `{"module":"mesh-sdk","version":"1","build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript","paths":["src","package.json"]}]}}` @@ -69,26 +69,37 @@ func TestACandidatesPackageIsBuiltAndPackedAndNeverPublished(t *testing.T) { } } -// ADR 0498 §5: a trunk build (publish) publishes a version the registry lacks and answers @; the same -// packed file already there is done without a publish; another file under that version fails `version taken:`. The -// packed file is gone from the node afterwards. +// ADR 0498 §5, CAT-BN3 (design 51 *Publishing*): a trunk build the builder gave a version packs the package under that +// version and publishes it through npm-package-registry, never by npm publish nor with a credential, and answers +// @; its package.json is put back. A trunk build not numbered publishes nothing; a refusal fails it +// not stored. The packed file is gone from the node afterwards. func TestATrunkPackageIsPublishedOnceAndAVersionTakenIsRefused(t *testing.T) { - tgz := tgzOf(t, sdkPackage, "0.1.15") - args := map[string]any{"workspace": "w1", "module": ".", "artifact": "lib", "publish": true} + tgz := tgzOf(t, sdkPackage, "0.1.16") + args := map[string]any{"workspace": "w1", "module": ".", "artifact": "lib", "publish": true, "version": "0.1.16", "seat": "npm-package-registry"} m, f, work := packageMachine(t, tgz) - fakeRegistry(t, m, nil) - out, err := m.build(context.Background(), args) - if err != nil || out.(BuildResult).Reference != "@novox/mesh-sdk@0.1.15" || len(publishes(f)) != 1 { - t.Fatalf("an absent version: %+v %v, %d publishes", out, err, len(publishes(f))) - } - if p := publishes(f)[0]; !slices.Contains(p.args, "--ignore-scripts") || !strings.HasSuffix(p.args[1], ".tgz") { - t.Errorf("published with %v", p.args) - } - // ADR 0500 §4: the registry's credential is never in a workspace, which a run step is given. - for i, a := range publishes(f)[0].args { - if a == "--userconfig" && strings.HasPrefix(publishes(f)[0].args[i+1], work) { - t.Errorf("the credential was written into the workspace: %s", publishes(f)[0].args[i+1]) + var packedAs string + inner := f.effect + f.effect = func(c call) { + if c.name == "npm" && len(c.args) > 0 && c.args[0] == "pack" { + raw, _ := os.ReadFile(filepath.Join(work, "w1", "tree", "package.json")) + packedAs = string(raw) } + inner(c) + } + store := withStore(t, m) + out, err := m.build(context.Background(), args) + if err != nil || out.(BuildResult).Reference != "@novox/mesh-sdk@0.1.16" || len(publishes(f)) != 0 { + t.Fatalf("a numbered trunk package: %+v %v, %d npm publishes", out, err, len(publishes(f))) + } + if !strings.Contains(packedAs, `"version": "0.1.16"`) { + t.Errorf("packed with package.json %s, want the builder's version", packedAs) + } + if raw, _ := os.ReadFile(filepath.Join(work, "w1", "tree", "package.json")); !strings.Contains(string(raw), `"version":"0.1.15"`) { + t.Errorf("package.json was not put back: %s", raw) + } + if len(store.published) != 1 || store.published[0].Name != "@novox/mesh-sdk" || store.published[0].Version != "0.1.16" || + store.published[0].Kind != "package" || store.seats[0] != "npm-package-registry" || store.published[0].Digest != digestOf(tgz) { + t.Errorf("published %+v through %v", store.published, store.seats) } var left []string filepath.WalkDir(work, func(p string, d os.DirEntry, err error) error { @@ -102,15 +113,15 @@ func TestATrunkPackageIsPublishedOnceAndAVersionTakenIsRefused(t *testing.T) { } m, f, _ = packageMachine(t, tgz) - fakeRegistry(t, m, map[string][]byte{"0.1.15": tgz}) - if out, err := m.build(context.Background(), args); err != nil || out.(BuildResult).Reference != "@novox/mesh-sdk@0.1.15" || len(publishes(f)) != 0 { - t.Errorf("the same file published before: %+v %v, %d publishes", out, err, len(publishes(f))) + store = withStore(t, m) + unnumbered := map[string]any{"workspace": "w1", "module": ".", "artifact": "lib", "publish": true} + if out, err := m.build(context.Background(), unnumbered); err != nil || out.(BuildResult).Reference != "" || len(store.published) != 0 || len(publishes(f)) != 0 { + t.Errorf("a trunk package not numbered: %+v %v, %d published", out, err, len(store.published)) } - m, f, _ = packageMachine(t, tgz) - fakeRegistry(t, m, map[string][]byte{"0.1.15": tgzOf(t, sdkPackage, "another")}) - _, err = m.build(context.Background(), args) - if err == nil || !strings.HasPrefix(err.Error(), delivery.VersionTaken+"@novox/mesh-sdk@0.1.15") || len(publishes(f)) != 0 { - t.Errorf("another file under the version: %v, %d publishes", err, len(publishes(f))) + m, _, _ = packageMachine(t, tgz) + withStore(t, m).fail = errors.New("refused: the builder did not number it") + if _, err = m.build(context.Background(), args); err == nil || !strings.HasPrefix(err.Error(), builds.NotStored+"lib: ") { + t.Errorf("a refused publish: %v", err) } } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/push.go b/modules/mesh-build-node/cmd/mesh-build-node/push.go deleted file mode 100644 index fb468a033..000000000 --- a/modules/mesh-build-node/cmd/mesh-build-node/push.go +++ /dev/null @@ -1,42 +0,0 @@ -package main - -// The push of a done build's archive (novox/hq ADR 0498 §1 §2; issue 1587): from this module's own process, after the -// build step ended, with mesh-sdk's one client (buildstep.PushBlob), to the artifact store whose address the manifest -// binds into RegistryFile. No build step reaches the store: a run step has no network (ADR 0448). - -import ( - "context" - "fmt" - "net/http" - "os" - "strings" - "time" - - "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" -) - -// registry is the artifact store's address, host:port, as the manifest bound it into RegistryFile. -func (m *Module) registry() (string, error) { - raw, err := os.ReadFile(m.RegistryFile) - if err != nil { - return "", fmt.Errorf("the artifact store's address cannot be read: %w", err) - } - addr := strings.TrimSpace(string(raw)) - if addr == "" || strings.Contains(addr, "${") || strings.HasPrefix(addr, ":") || strings.HasSuffix(addr, ":") { - return "", fmt.Errorf("the artifact store's address in %s is %q, not host:port", m.RegistryFile, addr) - } - return addr, nil -} - -// push puts body into the artifact store as a blob of repository (/) by its digest: Push when a test -// set it, else buildstep.PushBlob. -func (m *Module) push(ctx context.Context, repository, digest string, body []byte) error { - addr, err := m.registry() - if err != nil { - return err - } - if m.Push != nil { - return m.Push(ctx, addr, repository, digest, body) - } - return buildstep.PushBlob(ctx, &http.Client{Timeout: 10 * time.Minute}, addr, repository, digest, body) -} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/push_test.go b/modules/mesh-build-node/cmd/mesh-build-node/push_test.go index 50cbe7e97..4854a59e3 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/push_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/push_test.go @@ -3,7 +3,9 @@ package main import ( "context" "crypto/sha256" + "encoding/base64" "encoding/hex" + "encoding/json" "errors" "os" "path/filepath" @@ -11,54 +13,87 @@ import ( "sync" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" + registry "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + + "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" + builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" ) -// pushed are the blobs the fake artifact store took, by digest, for the tests that read an archive back. +// pushed are the blobs the fake holder took, by digest, for the tests that read an archive back. var pushed sync.Map -// fakeStore is a store of the test's own: its address in RegistryFile, each blob kept by repository and digest; fail, -// when set, is the store's refusal. +// fakeStore is the registry seats' holder as m's publish meets it (publish.go; CAT-BN3): publish opens a transfer and +// answers the part it lacks, publish-part takes the chunks in sequence, and the last one keeps the whole part when it +// hashes to its digest. Each file is kept by name and digest; fail, when set, is the holder's refusal at the last chunk. type fakeStore struct { - mu sync.Mutex - blobs map[string][]byte // "@" - fail error + mu sync.Mutex + blobs map[string][]byte // "@" + fail error + open map[string]registry.PublishInput + chunks map[string][]byte + published []registry.PublishInput + seats []string } func withStore(t *testing.T, m *Module) *fakeStore { t.Helper() - s := &fakeStore{blobs: map[string][]byte{}} - m.RegistryFile = filepath.Join(t.TempDir(), "registry") - if err := os.WriteFile(m.RegistryFile, []byte("store.test:5000\n"), 0o644); err != nil { - t.Fatal(err) - } - m.Push = func(ctx context.Context, registry, repository, digest string, body []byte) error { + s := &fakeStore{blobs: map[string][]byte{}, open: map[string]registry.PublishInput{}, chunks: map[string][]byte{}} + m.Call = func(key string, body any) (json.RawMessage, error) { s.mu.Lock() defer s.mu.Unlock() - if s.fail != nil { - return s.fail + raw, _ := json.Marshal(body) + seat, verb, _ := strings.Cut(strings.TrimPrefix(key, "seat:"), ".") + if seat != buildstep.ArtifactSeat && seat != buildstep.NpmSeat { + return nil, errors.New("nats: no responders available for request") } - if sum := sha256.Sum256(body); "sha256:"+hex.EncodeToString(sum[:]) != digest || registry != "store.test:5000" { - t.Errorf("pushed %s to %s with bytes of another digest", digest, registry) + switch verb { + case "publish": + var in registry.PublishInput + _ = json.Unmarshal(raw, &in) + if (seat == buildstep.ArtifactSeat) != (in.Kind == "bundle") || in.Transfer == "" || strings.ContainsAny(in.Transfer, "/. *>") { + t.Errorf("publish on %s of kind %q, transfer %q", seat, in.Kind, in.Transfer) + } + s.open[in.Transfer] = in + s.seats = append(s.seats, seat) + return json.Marshal(registry.PublishAnswer{Transfer: in.Transfer, Lacks: []string{in.Digest}}) + case "publish-part": + var c registry.PublishPartInput + _ = json.Unmarshal(raw, &c) + data, _ := base64.StdEncoding.DecodeString(c.Data) + s.chunks[c.Transfer] = append(s.chunks[c.Transfer], data...) + if !c.Last { + return json.Marshal(registry.PublishPartAnswer{Transfer: c.Transfer, Part: c.Part}) + } + in, whole := s.open[c.Transfer], s.chunks[c.Transfer] + delete(s.chunks, c.Transfer) + if s.fail != nil { + return nil, s.fail + } + if sum := sha256.Sum256(whole); "sha256:"+hex.EncodeToString(sum[:]) != in.Digest { + t.Errorf("published %s with bytes of another digest", in.Digest) + } + s.blobs[in.Name+"@"+in.Digest] = whole + s.published = append(s.published, in) + pushed.Store(in.Digest, whole) + return json.Marshal(registry.PublishPartAnswer{Transfer: c.Transfer, Part: c.Part, Complete: true}) } - s.blobs[repository+"@"+digest] = append([]byte(nil), body...) - pushed.Store(digest, s.blobs[repository+"@"+digest]) - return nil + return nil, errors.New("nats: no responders available for request") } return s } -// archiveOf is the archive a build pushed, by its digest. +// archiveOf is the archive a build published, by its digest. func archiveOf(t *testing.T, r BuildResult) []byte { t.Helper() raw, ok := pushed.Load(r.Digest) if !ok { - t.Fatalf("nothing was pushed under %s", r.Digest) + t.Fatalf("nothing was published under %s", r.Digest) } return raw.([]byte) } -// 1587-S6 (ADR 0498 §1): a done bundle answers the reference of the archive it pushed, under the manifest's module. +// 1587-S6 (ADR 0498 §1; CAT-BN3): a done bundle is published through oci-artifact-registry and answers the seat's +// reference of its archive, under the manifest's module. func TestADoneBundleAnswersTheReferenceOfTheArchiveItPushed(t *testing.T) { m, _, _ := builtMachine(t, ``) store := withStore(t, m) @@ -67,23 +102,33 @@ func TestADoneBundleAnswersTheReferenceOfTheArchiveItPushed(t *testing.T) { t.Fatal(err) } r := out.(BuildResult) - if want := "artifact-store://m/tools/blobs/" + r.Digest; r.Reference != want { + if want := "oci-artifact-registry/m/tools@" + r.Digest; r.Reference != want { t.Errorf("reference %q, want %q", r.Reference, want) } - if store.blobs["m/tools@"+r.Digest] == nil { - t.Errorf("the store holds %d blobs, not m/tools@%s", len(store.blobs), r.Digest) + if store.blobs["m/tools@"+r.Digest] == nil || len(store.seats) != 1 || store.seats[0] != buildstep.ArtifactSeat { + t.Errorf("the holder holds %d files through %v, not m/tools@%s through %s", len(store.blobs), store.seats, r.Digest, buildstep.ArtifactSeat) } } -// 1587-S7 and S8 (ADR 0498 §2, the lead's decision L9): a push the store refuses fails the step `not stored: -// : `, and whatever the push answered, no packed file is left on the node. +// 1587-S7 and S8 (ADR 0498 §2, the lead's decision L9): a publish the holder refuses fails the step `not stored: +// : `, a seat nothing holds too, and whatever it answered, no packed file is left. func TestARefusedPushFailsNotStoredAndNoPackedFileStays(t *testing.T) { - for _, fail := range []error{nil, errors.New("store.test:5000 refused the blob: 507 Insufficient Storage")} { + for _, c := range []struct { + fail error + noSeat bool + words string + }{{nil, false, ""}, {errors.New("refused: the store did not take it: 507 Insufficient Storage"), false, "507"}, + {nil, true, "no responders"}} { m, _, work := builtMachine(t, ``) - withStore(t, m).fail = fail + withStore(t, m).fail = c.fail + if c.noSeat { + m.Call = func(string, any) (json.RawMessage, error) { + return nil, errors.New("nats: no responders available for request") + } + } _, err := m.build(context.Background(), buildArgs()) - if fail != nil && (err == nil || !strings.HasPrefix(err.Error(), delivery.NotStored+"tools: ") || !strings.Contains(err.Error(), "507")) { - t.Errorf("a refused push answered %v", err) + if c.words != "" && (err == nil || !strings.HasPrefix(err.Error(), builds.NotStored+"tools: ") || !strings.Contains(err.Error(), c.words)) { + t.Errorf("a refused publish answered %v", err) } var archives []string filepath.WalkDir(work, func(p string, d os.DirEntry, err error) error { @@ -93,7 +138,7 @@ func TestARefusedPushFailsNotStoredAndNoPackedFileStays(t *testing.T) { return nil }) if len(archives) != 0 { - t.Errorf("push answered %v: packed files left on the node: %v", fail, archives) + t.Errorf("publish answered %v: packed files left on the node: %v", c.fail, archives) } } } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/read.go b/modules/mesh-build-node/cmd/mesh-build-node/read.go index 8c4463d8e..d28fe95e9 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/read.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/read.go @@ -14,8 +14,8 @@ import ( "sync" "time" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/delivery" ) // The read tools, named as the module is: build_node_ and the verb. @@ -98,26 +98,26 @@ func (m *Module) stepKey(id string) (string, error) { } id = rest } - return delivery.StepKey(m.Node, id) + return sdkstep.StepKey(m.Node, id) } // ReadStep is the stored value of this node's step id (state, reason, result). -func (m *Module) ReadStep(id string) (delivery.Step, error) { +func (m *Module) ReadStep(id string) (sdkstep.Step, error) { key, err := m.stepKey(id) if err != nil { - return delivery.Step{}, err + return sdkstep.Step{}, err } if m.ReadSteps.Get == nil { - return delivery.Step{}, fmt.Errorf("the steps state cannot be read here") + return sdkstep.Step{}, fmt.Errorf("the steps state cannot be read here") } raw, err := m.ReadSteps.Get(key) if err != nil { - return delivery.Step{}, err + return sdkstep.Step{}, err } if len(raw) == 0 { - return delivery.Step{}, fmt.Errorf("no step %s in the steps state", key) + return sdkstep.Step{}, fmt.Errorf("no step %s in the steps state", key) } - return delivery.DecodeStep(raw) + return sdkstep.DecodeStep(raw) } // StepLine is a step without its result: the list answers these. @@ -150,7 +150,7 @@ func (m *Module) RecentSteps(limit int) (lines []StepLine, cut bool, err error) own := keys[:0:0] for _, k := range keys { if strings.HasPrefix(k, m.Node+".") { - if node, _, err := delivery.ParseStepKey(k); err == nil && node == m.Node { + if node, _, err := sdkstep.ParseStepKey(k); err == nil && node == m.Node { own = append(own, k) } } @@ -164,12 +164,12 @@ func (m *Module) RecentSteps(limit int) (lines []StepLine, cut bool, err error) if err != nil { return nil, cut, err } - var steps []delivery.Step + var steps []sdkstep.Step for _, raw := range raws { if len(raw) == 0 { continue } - s, err := delivery.DecodeStep(raw) + s, err := sdkstep.DecodeStep(raw) if err != nil { continue // a value this module cannot read is not one of its own steps } @@ -191,7 +191,7 @@ func (m *Module) RecentSteps(limit int) (lines []StepLine, cut bool, err error) } // ReadToolchains is every key of the toolchains state (.) with its stored value. -func (m *Module) ReadToolchains() (map[string]delivery.Toolchain, error) { +func (m *Module) ReadToolchains() (map[string]sdkstep.Toolchain, error) { if m.ReadToolchainsState.Keys == nil || m.ReadToolchainsState.Get == nil { return nil, fmt.Errorf("the toolchains state cannot be read here") } @@ -209,12 +209,12 @@ func (m *Module) ReadToolchains() (map[string]delivery.Toolchain, error) { if err != nil { return nil, err } - out := map[string]delivery.Toolchain{} + out := map[string]sdkstep.Toolchain{} for i, raw := range raws { if len(raw) == 0 { continue } - t, err := delivery.DecodeToolchain(raw) + t, err := sdkstep.DecodeToolchain(raw) if err != nil { return nil, fmt.Errorf("%s: %w", keys[i], err) } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/read_test.go b/modules/mesh-build-node/cmd/mesh-build-node/read_test.go index a54951fb4..194d94d5e 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/read_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/read_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) // fakeState is a state as stored: key to raw value. @@ -29,13 +29,13 @@ func (f fakeState) read() StateRead { } } -func putStep(t *testing.T, f fakeState, s delivery.Step) { +func putStep(t *testing.T, f fakeState, s sdkstep.Step) { t.Helper() - b, err := delivery.EncodeStep(s) + b, err := sdkstep.EncodeStep(s) if err != nil { t.Fatal(err) } - key, _ := delivery.StepKey(s.Node, s.ID) + key, _ := sdkstep.StepKey(s.Node, s.ID) f[key] = b } @@ -60,22 +60,22 @@ func tool(t *testing.T, m *Module, name string) func(map[string]any) (any, error func TestStepToolAnswersTheStoredStep(t *testing.T) { steps := fakeState{} t0 := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC) - putStep(t, steps, delivery.Step{ID: "abc1", Tool: "build_node_fetch", Node: "ace", State: delivery.StepPassed, Started: t0, Ended: t0.Add(time.Second), + putStep(t, steps, sdkstep.Step{ID: "abc1", Tool: "build_node_fetch", Node: "ace", State: sdkstep.StepPassed, Started: t0, Ended: t0.Add(time.Second), Result: json.RawMessage(`{"workspace":"w42"}`)}) - putStep(t, steps, delivery.Step{ID: "abc2", Tool: "build_node_test", Node: "ace", State: delivery.StepRefused, Started: t0, Reason: "node cannot be run"}) + putStep(t, steps, sdkstep.Step{ID: "abc2", Tool: "build_node_test", Node: "ace", State: sdkstep.StepRefused, Started: t0, Reason: "node cannot be run"}) m := readModule(steps, fakeState{}) for _, id := range []string{"abc1", "ace.abc1"} { out, err := tool(t, m, StepReadTool)(map[string]any{"id": id}) if err != nil { t.Fatal(err) } - st := out.(delivery.Step) - if st.State != delivery.StepPassed || string(st.Result) != `{"workspace":"w42"}` || st.Tool != "build_node_fetch" { + st := out.(sdkstep.Step) + if st.State != sdkstep.StepPassed || string(st.Result) != `{"workspace":"w42"}` || st.Tool != "build_node_fetch" { t.Errorf("id %s: step = %+v", id, st) } } out, _ := tool(t, m, StepReadTool)(map[string]any{"id": "abc2"}) - if st := out.(delivery.Step); st.State != delivery.StepRefused || st.Reason != "node cannot be run" { + if st := out.(sdkstep.Step); st.State != sdkstep.StepRefused || st.Reason != "node cannot be run" { t.Errorf("refused step = %+v", st) } } @@ -94,10 +94,10 @@ func TestStepToolRefusesAnUnknownOrForeignOrBadID(t *testing.T) { func TestStepsToolListsRecentStepsOfThisNode(t *testing.T) { steps := fakeState{} t0 := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC) - putStep(t, steps, delivery.Step{ID: "a", Tool: "build_node_fetch", Node: "ace", State: delivery.StepPassed, Started: t0, Ended: t0, Result: json.RawMessage(`1`)}) - putStep(t, steps, delivery.Step{ID: "b", Tool: "build_node_test", Node: "ace", State: delivery.StepRunning, Started: t0.Add(time.Minute)}) - putStep(t, steps, delivery.Step{ID: "c", Tool: "build_node_build", Node: "ace", State: delivery.StepFailed, Started: t0.Add(2 * time.Minute), Reason: "boom", What: "check ask, novox/x@abc"}) - putStep(t, steps, delivery.Step{ID: "d", Tool: "build_node_build", Node: "g14", State: delivery.StepPassed, Started: t0.Add(time.Hour)}) + putStep(t, steps, sdkstep.Step{ID: "a", Tool: "build_node_fetch", Node: "ace", State: sdkstep.StepPassed, Started: t0, Ended: t0, Result: json.RawMessage(`1`)}) + putStep(t, steps, sdkstep.Step{ID: "b", Tool: "build_node_test", Node: "ace", State: sdkstep.StepRunning, Started: t0.Add(time.Minute)}) + putStep(t, steps, sdkstep.Step{ID: "c", Tool: "build_node_build", Node: "ace", State: sdkstep.StepFailed, Started: t0.Add(2 * time.Minute), Reason: "boom", What: "check ask, novox/x@abc"}) + putStep(t, steps, sdkstep.Step{ID: "d", Tool: "build_node_build", Node: "g14", State: sdkstep.StepPassed, Started: t0.Add(time.Hour)}) m := readModule(steps, fakeState{}) out, err := tool(t, m, StepsReadTool)(map[string]any{}) if err != nil { @@ -111,7 +111,7 @@ func TestStepsToolListsRecentStepsOfThisNode(t *testing.T) { if !slices.Equal(ids, []string{"c", "b", "a"}) { t.Errorf("ids = %v, want c b a", ids) } - if lines[0].Reason != "boom" || lines[0].What != "check ask, novox/x@abc" || lines[0].State != delivery.StepFailed { + if lines[0].Reason != "boom" || lines[0].What != "check ask, novox/x@abc" || lines[0].State != sdkstep.StepFailed { t.Errorf("first = %+v", lines[0]) } out, _ = tool(t, m, StepsReadTool)(map[string]any{"limit": float64(1)}) @@ -123,7 +123,7 @@ func TestStepsToolListsRecentStepsOfThisNode(t *testing.T) { // 1562 (1483/AC5): build_node_toolchains answers each toolchains key as stored. func TestToolchainsToolAnswersTheStoredKeys(t *testing.T) { tcs := fakeState{} - b, err := delivery.EncodeToolchain(delivery.Toolchain{Language: "typescript", Version: "v22.1.0", Recipe: "1", At: time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)}) + b, err := sdkstep.EncodeToolchain(sdkstep.Toolchain{Language: "typescript", Version: "v22.1.0", Recipe: "1", At: time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)}) if err != nil { t.Fatal(err) } @@ -133,7 +133,7 @@ func TestToolchainsToolAnswersTheStoredKeys(t *testing.T) { if err != nil { t.Fatal(err) } - got := out.(map[string]any)["toolchains"].(map[string]delivery.Toolchain) + got := out.(map[string]any)["toolchains"].(map[string]sdkstep.Toolchain) if got["ace.typescript"].Version != "v22.1.0" || got["ace.typescript"].Recipe != "1" { t.Errorf("toolchains = %+v", got) } @@ -166,7 +166,7 @@ func TestRecentStepsReadsOnlyTheNodesKeysInParallelAndBounded(t *testing.T) { stored := map[string][]byte{} for _, n := range []string{"ace", "g14", "novox", "shanks"} { for i := 0; i < row.perNode; i++ { - b, _ := delivery.EncodeStep(delivery.Step{ID: fmt.Sprintf("s%04d", i), Tool: "t", Node: n, State: delivery.StepPassed, Started: t0.Add(time.Duration(i) * time.Second)}) + b, _ := sdkstep.EncodeStep(sdkstep.Step{ID: fmt.Sprintf("s%04d", i), Tool: "t", Node: n, State: sdkstep.StepPassed, Started: t0.Add(time.Duration(i) * time.Second)}) stored[n+".s"+fmt.Sprintf("%04d", i)] = b } } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/registry.go b/modules/mesh-build-node/cmd/mesh-build-node/registry.go index e7b8e8597..fa5a0720c 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/registry.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/registry.go @@ -3,16 +3,13 @@ package main // How the toolchain's install reaches the mesh's package registry (novox/hq ADR 0405 §5, ADR 0336; issue 1488). // The lock names @novox/mesh-sdk at the registry's public address, and npm installs a locked tarball only from the // registry it is configured with, so the install is told that registry for the @novox scope and nothing else: every -// other package resolves from the public one. This is how today's build agent names it (mesh-controller -// internal/builder/dependencies.go: the scope's registry from the npm-package-registry binding, the account and -// secret the mesh sealed), read here from the module's own binding and secret files (module.json `binds` and -// `secrets`), the same files the lab module reads. +// other package resolves from the public one. The address is read from the module's npm-package-registry binding +// (module.json `binds`). Reads are anonymous (ADR 0346; the provision's form none, CAT-BN3): this module holds no +// credential for the registry, and publishes only through the seat's verbs (publish.go). import ( - "encoding/base64" "encoding/json" "fmt" - "net/http" "os" "strings" ) @@ -22,19 +19,18 @@ const Scope = "@novox" // Registry is the mesh's package registry as the binding names it. type Registry struct { - URL, Username, Password string + URL string } -// ReadRegistry reads the npm-package-registry binding and its sealed secret. The registry is the binding's +// ReadRegistry reads the npm-package-registry binding. The registry is the binding's // `public-root` when it has one (the address the lock holds), and otherwise scheme://at:port plus `npm-path`. -func ReadRegistry(bindingPath, secretPath string) (Registry, error) { +func ReadRegistry(bindingPath string) (Registry, error) { raw, err := os.ReadFile(bindingPath) if err != nil { return Registry{}, fmt.Errorf("no package registry is bound to this module: %w", err) } var b struct { At string `json:"at"` - As string `json:"as"` Serves struct { Scheme string `json:"scheme"` Port int `json:"port"` @@ -59,31 +55,10 @@ func ReadRegistry(bindingPath, secretPath string) (Registry, error) { if !strings.HasSuffix(url, "/") { url += "/" } - secret, err := os.ReadFile(secretPath) - if err != nil { - return Registry{}, fmt.Errorf("the package registry's secret cannot be read: %w", err) - } - return Registry{URL: url, Username: b.As, Password: strings.TrimSpace(string(secret))}, nil + return Registry{URL: url}, nil } -// Npmrc is the npm configuration text for the install: the @novox scope at the registry, with the account's -// credential for it when there is one. +// Npmrc is the npm configuration text for the install: the @novox scope at the registry, read anonymously. func (r Registry) Npmrc() string { - var sb strings.Builder - fmt.Fprintf(&sb, "%s:registry=%s\n", Scope, r.URL) - if r.Username != "" && r.Password != "" { - hostPath := r.URL - if i := strings.Index(hostPath, "://"); i >= 0 { - hostPath = hostPath[i+1:] - } - fmt.Fprintf(&sb, "%s:_auth=%s\n", hostPath, base64.StdEncoding.EncodeToString([]byte(r.Username+":"+r.Password))) - } - return sb.String() -} - -// authorize gives a request to the registry the account's credential, when there is one. -func (r Registry) authorize(req *http.Request) { - if r.Username != "" && r.Password != "" { - req.SetBasicAuth(r.Username, r.Password) - } + return fmt.Sprintf("%s:registry=%s\n", Scope, r.URL) } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/room_test.go b/modules/mesh-build-node/cmd/mesh-build-node/room_test.go index 07bea7a4b..ae0266872 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/room_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/room_test.go @@ -9,7 +9,7 @@ import ( "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" - "git.novox.be/novox/mesh-sdk/go/delivery" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/forgekey" "git.novox.be/novox/mesh-sdk/go/health" ) @@ -44,7 +44,7 @@ func TestUnderTheFloorEveryStepIsRefusedAndTheModuleIsUnhealthy(t *testing.T) { if _, err := tool.Run(map[string]any{}); err != nil { t.Fatal(err) } - if st := states.waitEnd(t); st.State != delivery.StepRefused || !strings.HasPrefix(st.Reason, underTheFloor) { + if st := states.waitEnd(t); st.State != sdkstep.StepRefused || !strings.HasPrefix(st.Reason, underTheFloor) { t.Errorf("the long step ended %s: %q, want refused: %q", st.State, st.Reason, underTheFloor) } case "short_probe": diff --git a/modules/mesh-build-node/cmd/mesh-build-node/store_integration_test.go b/modules/mesh-build-node/cmd/mesh-build-node/store_integration_test.go index 1ad3d262e..7776637c6 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/store_integration_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/store_integration_test.go @@ -3,14 +3,16 @@ package main import ( + "bytes" "context" "crypto/sha256" "encoding/hex" + "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" - "os" - "path/filepath" + "net/url" "strings" "testing" @@ -18,12 +20,16 @@ import ( "github.com/distribution/distribution/v3/registry/handlers" _ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" + artifactref "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient/reference" + registry "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry" + builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" ) // realStore starts the artifact store's own program (the distribution registry, modules/distribution) inside this -// test, with its in-memory storage, on a loopback port, and points m at it with the real push client. It is imported -// by this integration test alone: the module's program does not carry it. +// test, with its in-memory storage, on a loopback port, and plays the artifact seat's holder storing into it: the fake +// holder (push_test.go) takes the publish, and each file it took is uploaded into the store as distribution's holder +// does. It is imported by this integration test alone: the module's program does not carry it. func realStore(t *testing.T, m *Module) string { t.Helper() config := &configuration.Configuration{Storage: configuration.Storage{"inmemory": configuration.Parameters{}}} @@ -33,14 +39,57 @@ func realStore(t *testing.T, m *Module) string { srv := httptest.NewServer(handlers.NewApp(context.Background(), config)) t.Cleanup(srv.Close) addr := strings.TrimPrefix(srv.URL, "http://") - m.RegistryFile = filepath.Join(t.TempDir(), "registry") - if err := os.WriteFile(m.RegistryFile, []byte(addr+"\n"), 0o644); err != nil { - t.Fatal(err) + s := withStore(t, m) + inner := m.Call + m.Call = func(key string, body any) (json.RawMessage, error) { + raw, err := inner(key, body) + var a registry.PublishPartAnswer + if err != nil || !strings.HasSuffix(key, ".publish-part") || json.Unmarshal(raw, &a) != nil || !a.Complete { + return raw, err + } + s.mu.Lock() + defer s.mu.Unlock() + for k, b := range s.blobs { + name, digest, _ := strings.Cut(k, "@") + if err := uploadBlob(srv.URL, name, digest, b); err != nil { + return nil, err + } + delete(s.blobs, k) + } + return raw, nil } - m.Push = nil return addr } +// uploadBlob is the store's monolithic blob upload, a POST opening it and a PUT with the digest, as the holder does. +func uploadBlob(base, name, digest string, body []byte) error { + resp, err := http.Post(base+"/v2/"+name+"/blobs/uploads/", "", nil) + if err != nil { + return err + } + resp.Body.Close() + loc, err := url.Parse(resp.Header.Get("Location")) + if err != nil || resp.StatusCode != http.StatusAccepted { + return fmt.Errorf("the store opened no upload of %s: %s %v", digest, resp.Status, err) + } + b, _ := url.Parse(base) + u := b.ResolveReference(loc) + q := u.Query() + q.Set("digest", digest) + u.RawQuery = q.Encode() + req, _ := http.NewRequest(http.MethodPut, u.String(), bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/octet-stream") + put, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + put.Body.Close() + if put.StatusCode != http.StatusCreated { + return fmt.Errorf("the store did not take %s: %s", digest, put.Status) + } + return nil +} + // 1587-S10 (ADR 0498's check): a done bundle names a reference whose tail is its digest, and that reference resolves in // a real artifact store to bytes of that digest. The build is the real tsc's and esbuild's, shut off. func TestADoneBundleResolvesInARealArtifactStore(t *testing.T) { @@ -52,16 +101,16 @@ func TestADoneBundleResolvesInARealArtifactStore(t *testing.T) { t.Fatal(err) } r := out.(BuildResult) - done := delivery.BuilderBuild{V: 1, State: delivery.BuildDone, Manifest: strings.Repeat("0", 64), - Artifacts: []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Digest: r.Digest, Reference: r.Reference}}} - if _, err := delivery.EncodeBuilderBuild(done); err != nil { + done := builds.BuilderBuild{V: 1, State: builds.BuildDone, Manifest: strings.Repeat("0", 64), + Artifacts: []builds.BuiltArtifact{{Name: "tools", Kind: "bundle", Digest: r.Digest, Reference: r.Reference}}} + if _, err := builds.EncodeBuilderBuild(done); err != nil { t.Errorf("the done key the builder would write is refused: %v", err) } - path, ok := strings.CutPrefix(r.Reference, "artifact-store://") - if !ok || !strings.HasSuffix(r.Reference, "/blobs/"+r.Digest) { - t.Fatalf("the reference %q", r.Reference) + if _, _, ok := registryForms.InRegistrySeat(r.Reference); !ok || !strings.HasSuffix(r.Reference, "@"+r.Digest) { + t.Fatalf("the reference %q is not in the form a registry seat answers", r.Reference) } - resp, err := http.Get("http://" + addr + "/v2/" + path) + // Read as a node-engine reads it: the seat's reference routed to the store's address. + resp, err := http.Get(registryForms.Routed(r.Reference, addr)) if err != nil { t.Fatal(err) } @@ -72,3 +121,6 @@ func TestADoneBundleResolvesInARealArtifactStore(t *testing.T) { t.Errorf("the store answered %s with bytes of sha256:%x for %s", resp.Status, sum, r.Reference) } } + +// registryForms reads a reference of the registry form, the two seats' names from the build step (their contracts). +var registryForms = artifactref.Seats{Image: buildstep.ImageSeat, Object: buildstep.ArtifactSeat} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/toolchain.go b/modules/mesh-build-node/cmd/mesh-build-node/toolchain.go index 76d685218..08091c421 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/toolchain.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/toolchain.go @@ -25,7 +25,7 @@ const installedMark = ".installed" // Paths are the module's places on its machine. type Paths struct { // Home is the account's home (the state directory): where the mesh writes toolchain/package.json and - // toolchain/package-lock.json (module.json resources) and the registry binding and secret. + // toolchain/package-lock.json (module.json resources) and the registry binding. Home string // Cache is the cache directory, outside the home (ADR 0466 §1). Cache string diff --git a/modules/mesh-build-node/cmd/mesh-build-node/toolchain_test.go b/modules/mesh-build-node/cmd/mesh-build-node/toolchain_test.go index ccf06d4cf..8b208b60d 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/toolchain_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/toolchain_test.go @@ -49,7 +49,7 @@ func toolchainIn(t *testing.T, fake *fakeNpm) Toolchain { os.WriteFile(filepath.Join(home, "toolchain", "package-lock.json"), []byte(`{"lock":1}`), 0o600) return Toolchain{Paths: Paths{Home: home, Cache: cache}, Run: fake.run, Registry: func() (Registry, error) { - return Registry{URL: "https://git.novox.be/api/packages/novox/npm/", Username: "mesh-build-node", Password: "pw"}, nil + return Registry{URL: "https://git.novox.be/api/packages/novox/npm/"}, nil }} } @@ -106,13 +106,10 @@ func TestTheInstallNamesTheRegistryInAConfigFileNotOnTheCommandLine(t *testing.T if err := tc.Ensure(context.Background(), "v1"); err != nil { t.Fatal(err) } - if !strings.Contains(npmrc, "@novox:registry=https://git.novox.be/api/packages/novox/npm/\n") || - !strings.Contains(npmrc, "//git.novox.be/api/packages/novox/npm/:_auth=") { + // Reads are anonymous (ADR 0346, CAT-BN3): the scope's registry and no credential. + if npmrc != "@novox:registry=https://git.novox.be/api/packages/novox/npm/\n" { t.Errorf("npmrc = %q", npmrc) } - if strings.Contains(strings.Join(fake.calls[0].args, " "), "pw") { - t.Errorf("the credential is on the command line: %v", fake.calls[0].args) - } } // 1488/AC4: an install that fails leaves no directory taken for a toolchain, says why, and the next call tries again. @@ -158,18 +155,17 @@ func TestPrepareInstallsOnlyBehindAPassingGuard(t *testing.T) { func TestRegistryFromTheBinding(t *testing.T) { dir := t.TempDir() - b, s := filepath.Join(dir, "b.json"), filepath.Join(dir, "s") - os.WriteFile(s, []byte("secret\n"), 0o600) + b := filepath.Join(dir, "b.json") os.WriteFile(b, []byte(`{"provision":"npm-package-registry","at":"10.42.0.1","as":"mesh-build-node-ace","serves":{"scheme":"http","port":3000,"npm-path":"/api/packages/novox/npm/"}}`), 0o600) - r, err := ReadRegistry(b, s) - if err != nil || r.URL != "http://10.42.0.1:3000/api/packages/novox/npm/" || r.Username != "mesh-build-node-ace" || r.Password != "secret" { + r, err := ReadRegistry(b) + if err != nil || r.URL != "http://10.42.0.1:3000/api/packages/novox/npm/" || r.Npmrc() != "@novox:registry=http://10.42.0.1:3000/api/packages/novox/npm/\n" { t.Fatalf("= %+v, %v", r, err) } os.WriteFile(b, []byte(`{"at":"h","as":"a","serves":{"port":1,"npm-path":"/p/","public-root":"https://git.novox.be/p"}}`), 0o600) - if r, _ := ReadRegistry(b, s); r.URL != "https://git.novox.be/p/" { + if r, _ := ReadRegistry(b); r.URL != "https://git.novox.be/p/" { t.Errorf("the public root is not preferred: %q", r.URL) } - if _, err := ReadRegistry(filepath.Join(dir, "none"), s); err == nil { + if _, err := ReadRegistry(filepath.Join(dir, "none")); err == nil { t.Errorf("no binding was accepted") } } diff --git a/modules/mesh-build-node/files/registry b/modules/mesh-build-node/files/registry deleted file mode 100644 index 1bcadab26..000000000 --- a/modules/mesh-build-node/files/registry +++ /dev/null @@ -1 +0,0 @@ -${seat:mesh-artifact-store:reach} diff --git a/modules/mesh-build-node/go.mod b/modules/mesh-build-node/go.mod index 07c3aef00..6d3d86384 100644 --- a/modules/mesh-build-node/go.mod +++ b/modules/mesh-build-node/go.mod @@ -3,14 +3,19 @@ module meshbuildnode go 1.26.0 require ( - git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0 - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildnode v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 github.com/distribution/distribution/v3 v3.1.2 github.com/nats-io/nats.go v1.54.0 ) require ( - github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect + github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -29,10 +34,10 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hashicorp/golang-lru/arc/v2 v2.0.5 // indirect github.com/hashicorp/golang-lru/v2 v2.0.5 // indirect - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/minio/highwayhash v1.0.4 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/nats-io/jwt/v2 v2.8.1 // indirect + github.com/nats-io/jwt/v2 v2.8.2 // indirect github.com/nats-io/nats-server/v2 v2.11.17 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect @@ -75,7 +80,7 @@ require ( golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect golang.org/x/text v0.42.0 // indirect - golang.org/x/time v0.15.0 // indirect + golang.org/x/time v0.16.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect google.golang.org/grpc v1.83.2 // indirect @@ -83,3 +88,18 @@ require ( ) replace git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep => ../mesh-build-go/buildstep + +require git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys v0.0.0-00010101000000-000000000000 + +replace git.novox.be/novox/mesh-catalog/modules/ssh-client/sshkeys => ../ssh-client/sshkeys + +require ( + git.novox.be/novox/mesh-delivery/contracts/go/clients/gopackageregistryclient v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/clients/npmpackageregistryclient v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/clients/ociartifactregistryclient v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/seats/gopackageregistry v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/seats/npmpackageregistry v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000 // indirect +) diff --git a/modules/mesh-build-node/go.sum b/modules/mesh-build-node/go.sum index 003884e8c..c6e008fd0 100644 --- a/modules/mesh-build-node/go.sum +++ b/modules/mesh-build-node/go.sum @@ -1,9 +1,7 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w= @@ -54,16 +52,16 @@ github.com/hashicorp/golang-lru/arc/v2 v2.0.5 h1:l2zaLDubNhW4XO3LnliVj0GXO3+/CGN github.com/hashicorp/golang-lru/arc/v2 v2.0.5/go.mod h1:ny6zBSQZi2JxIeYcv7kt2sH2PXJtirBN7RDhRpxPkxU= github.com/hashicorp/golang-lru/v2 v2.0.5 h1:wW7h1TG88eUIJ2i69gaE3uNVtEPIagzhGvHgwfx2Vm4= github.com/hashicorp/golang-lru/v2 v2.0.5/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= -github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc= +github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc= github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= @@ -164,8 +162,8 @@ golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc= diff --git a/modules/mesh-build-node/module.json b/modules/mesh-build-node/module.json index 25f8611b4..c5b279ba2 100644 --- a/modules/mesh-build-node/module.json +++ b/modules/mesh-build-node/module.json @@ -1,6 +1,10 @@ { "module": "mesh-build-node", "version": "1", + "packages": { + "builds": "0.1", + "sshkeys": "0.1" + }, "moved": true, "runs-as": "mesh-build-node", "capabilities": [ @@ -63,20 +67,17 @@ "forge-account", "npm-package-registry", "node-toolchain", - "artifact-store", "node-tools" ], - "contributes": { - "npm-package-registry": { - "access": "write" - } - }, "binds": { "npm-package-registry": "${dir:state}/package-registry.json" }, - "secrets": { - "npm-package-registry": "${dir:state}/package-registry.secret" - }, + "invokes": [ + "seat:oci-artifact-registry.publish", + "seat:oci-artifact-registry.publish-part", + "seat:npm-package-registry.publish", + "seat:npm-package-registry.publish-part" + ], "own-secrets": { "broker": "${dir:state}/broker" }, @@ -164,14 +165,6 @@ "mode": "0644", "template": "files/gitconfig" }, - { - "id": "registry", - "type": "file", - "path": "${dir:state}/registry", - "owner": "mesh-build-node", - "mode": "0644", - "template": "files/registry" - }, { "id": "toolchain-dir", "type": "archive", diff --git a/modules/mesh-catalog/heard.ts b/modules/mesh-catalog/heard.ts index 619639ede..c8d58acd8 100644 --- a/modules/mesh-catalog/heard.ts +++ b/modules/mesh-catalog/heard.ts @@ -52,7 +52,6 @@ export interface Built { export const BUILDER = "mesh-delivery-builder"; export const BUILDS_SINCE_TOOL = "builder_builds_since"; export const BUILDS_STATE = "mesh-delivery-builder.builds"; -export const MANIFESTS_STATE = "mesh-delivery-indexer.manifests"; /** One entry of the builder's log, an ended build (mesh-sdk delivery.BuildLogEntry). */ export interface LogEntry { diff --git a/modules/mesh-catalog/index.ts b/modules/mesh-catalog/index.ts index cc0340028..c70d80ec3 100644 --- a/modules/mesh-catalog/index.ts +++ b/modules/mesh-catalog/index.ts @@ -15,7 +15,7 @@ import { invokeTool } from "@novox/mesh-sdk/tools"; import { state } from "@novox/mesh-sdk/state"; import { Graph } from "./store.js"; import { - answerOf, builtFrom, placeable, BUILDER, BUILDS_SINCE_TOOL, BUILDS_STATE, MANIFESTS_STATE, + answerOf, builtFrom, placeable, BUILDER, BUILDS_SINCE_TOOL, BUILDS_STATE, type Built, type BuildsSince, type LogEntry, } from "./heard.js"; @@ -94,11 +94,19 @@ let caughtUp = false; let asking: Promise | undefined; let askAgain = false; -const manifestOf = async (digest: string | undefined): Promise => { - if (!digest) return {}; - const held = await state<{ manifest?: string }>(MANIFESTS_STATE).get(digest); - if (!held?.value.manifest) return {}; - return JSON.parse(held.value.manifest); +/** + * **The indexer's manifests are no longer read** (novox/hq ADR 0615 §1, ruling U4): the module graph is the indexer's since + * ADR 0604, and this deprecated module's graph feeds nothing (no module consumes what it announces, and the seat + * `mesh-catalog` is the indexer's). Nothing replaces the read: a build is registered with an empty manifest, said once, until + * the module is retired. + */ +let manifestsSaid = false; +const manifestOf = async (_digest: string | undefined): Promise => { + if (!manifestsSaid) { + manifestsSaid = true; + console.error("mesh-catalog: deprecated; builds are registered without their manifests (the indexer's manifests are not read, ADR 0615)"); + } + return {}; }; const takeIn = async (e: LogEntry, replay: boolean): Promise => { diff --git a/modules/mesh-catalog/module.json b/modules/mesh-catalog/module.json index fe30560ed..c49699cbe 100644 --- a/modules/mesh-catalog/module.json +++ b/modules/mesh-catalog/module.json @@ -89,7 +89,6 @@ "mesh-delivery-builder.builder_builds_since" ], "reads": [ - "mesh-delivery-builder.builds", - "mesh-delivery-indexer.manifests" + "mesh-delivery-builder.builds" ] } diff --git a/modules/mesh-catalog/test/heard.test.ts b/modules/mesh-catalog/test/heard.test.ts index 391cb5e9c..b70b02703 100644 --- a/modules/mesh-catalog/test/heard.test.ts +++ b/modules/mesh-catalog/test/heard.test.ts @@ -2,7 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; -import { answerOf, builtFrom, placeable, BUILDER, BUILDS_SINCE_TOOL, BUILDS_STATE, MANIFESTS_STATE } from "../heard.js"; +import { answerOf, builtFrom, placeable, BUILDER, BUILDS_SINCE_TOOL, BUILDS_STATE } from "../heard.js"; // A walk's build as the build seat says it (mesh-controller's link.BuildResult), here mailu's of novox/hq issue 712. const walkBuild = { @@ -24,7 +24,8 @@ test("the catalogue takes builds from the builder's log, and hears no controller const manifest = JSON.parse(await readFile(new URL("../module.json", import.meta.url), "utf8")); assert.deepEqual(manifest.consumes, []); assert.ok(manifest.invokes.includes(`${BUILDER}.${BUILDS_SINCE_TOOL}`), "the builder's tool is not invoked"); - for (const read of [BUILDS_STATE, MANIFESTS_STATE]) assert.ok(manifest.reads.includes(read), `${read} is not read`); + // ADR 0615 §1, ruling U4: the indexer's manifests are not read (the module is deprecated and feeds nothing). + assert.deepEqual(manifest.reads, [BUILDS_STATE]); // A log entry is placed as a build: done with its manifest, made and against; failed passed over; queued not taken in. const entry = { event: "build.ended", module: "mailu", fp: "fp1", repository: walkBuild.repository, directory: "modules/mailu", commit: walkBuild.commit, ref: "main", at: "2026-10-29T20:00:00Z", outcome: "done", manifest: "ab", against: ["img@sha256:1"], diff --git a/modules/mesh-cli/README.md b/modules/mesh-cli/README.md index 2bb2687b8..1835cf7e2 100644 --- a/modules/mesh-cli/README.md +++ b/modules/mesh-cli/README.md @@ -33,8 +33,8 @@ module node-tools serves on loopback (novox/hq ADR 0152: whoever is on the machi puts the call on the bus, as every agent session on the machine does. The console's address is read from `/etc/mesh-cli/console`, or `MESH_CONSOLE` when set, or node-tools' declared port when the file is not there yet. -- **Who judges.** The seat that owns the job: a change of placement is a proposal the operator approves - (`mesh-placement.propose`), a config value is set with its why (`mesh-config.config`), a silence or a hand act is +- **Who judges.** The seat that owns the job: a change of assignments is a proposal the operator approves + (`mesh-module-assigner.propose`), a config value is set with its why (`mesh-config.config`), a silence or a hand act is recorded as one. Until novox/hq issue 1126 the controller ran these lines and judged the account (ADR 0272); with the controller unassigned, that judgement went with it. - **The map.** Every line the controller ran, and the seat verb or tool that does its job now, or none, is diff --git a/modules/mesh-cli/cmd/mesh-cli/commands.go b/modules/mesh-cli/cmd/mesh-cli/commands.go index 93f564057..71a971df7 100644 --- a/modules/mesh-cli/cmd/mesh-cli/commands.go +++ b/modules/mesh-cli/cmd/mesh-cli/commands.go @@ -111,17 +111,17 @@ var commands = []command{ // What runs where. {words: []string{"status"}, does: "what is wrong, what is quiet, what is moving (the delivery view)", build: fixed("mesh-delivery-view.view_status")}, - {words: []string{"nodes"}, does: "the nodes placement holds", build: fixed("mesh-placement.placement"), + {words: []string{"nodes"}, does: "the nodes assignments holds", build: fixed("mesh-module-assigner.assignments"), render: renderNodes}, - {words: []string{"node", "list"}, does: "the nodes placement holds", build: fixed("mesh-placement.placement"), + {words: []string{"node", "list"}, does: "the nodes assignments holds", build: fixed("mesh-module-assigner.assignments"), render: renderNodes}, - {words: []string{"node", "show"}, usage: "", does: "one node's placement: what it runs and its decided fields", - build: nodeArg("mesh-placement.placement")}, + {words: []string{"node", "show"}, usage: "", does: "one node's assignments: what it runs and its decided fields", + build: nodeArg("mesh-module-assigner.assignments")}, {words: []string{"node", "public-domain"}, usage: " [ | --clear] [--why ]", - does: "the domain a node composes its routed names under; with a domain or --clear, proposed to placement", + does: "the domain a node composes its routed names under; with a domain or --clear, proposed to assignments", allowed: []string{"clear", "why"}, build: publicDomain}, - {words: []string{"node", "add"}, instead: "a new machine joins through the seat mesh-enrolment: nox enrol issue --why "}, - {words: []string{"node", "networks"}, instead: "no seat keeps a node's routed networks yet; mesh-placement.placement shows what a node decides"}, + {words: []string{"node", "add"}, instead: "a new machine joins through the seat mesh-node-enroller: nox enrol issue --why "}, + {words: []string{"node", "networks"}, instead: "no seat keeps a node's routed networks yet; mesh-module-assigner.assignments shows what a node decides"}, {words: []string{"plan"}, usage: "", does: "what the assigner wants the node to run", build: optNode("mesh-delivery-view.assigner_wants")}, {words: []string{"haves"}, usage: "[]", does: "what each node runs, as its node-engine said", build: optNode("mesh-delivery-view.node_haves")}, {words: []string{"switch"}, does: "what each node runs, as its node-engine said", build: fixed("mesh-delivery-view.node_haves")}, @@ -133,9 +133,9 @@ var commands = []command{ return call{l.pos[0] + "/node-engine.composition", map[string]any{"module": l.pos[1]}}, nil }}, {words: []string{"seats"}, does: "who holds each seat, and where", build: func(line) (call, error) { - return call{"mesh-placement.placement", map[string]any{"seats": "true"}}, nil + return call{"mesh-module-assigner.assignments", map[string]any{"seats": "true"}}, nil }}, - {words: []string{"seat"}, instead: "a seat is held by placing its module: nox assign --why (mesh-placement.propose)"}, + {words: []string{"seat"}, instead: "a seat is held by placing its module: nox assign --why (mesh-module-assigner.propose)"}, {words: []string{"assign"}, usage: " ... --why ", does: "propose placing modules on a node", allowed: []string{"why"}, build: placeModules("place")}, {words: []string{"unassign"}, usage: " ... --why ", does: "propose taking modules off a node", @@ -155,9 +155,9 @@ var commands = []command{ } return propose(l, map[string]any{"op": "unpin", "node": l.pos[0], "provision": l.pos[1]}) }}, - {words: []string{"proposals"}, usage: "[]", does: "the open proposals of placement", build: optArg("mesh-placement.proposals", "id")}, - {words: []string{"overlay", "show"}, does: "every node's placement, with its tunnel", build: fixed("mesh-placement.placement")}, - {words: []string{"overlay"}, instead: "a node's tunnel is a decided field of its placement: mesh-placement.propose with op decide"}, + {words: []string{"proposals"}, usage: "[]", does: "the open proposals of assignments", build: optArg("mesh-module-assigner.proposals", "id")}, + {words: []string{"overlay", "show"}, does: "every node's assignments, with its tunnel", build: fixed("mesh-module-assigner.assignments")}, + {words: []string{"overlay"}, instead: "a node's tunnel is a decided field of its assignments: mesh-module-assigner.propose with op decide"}, // Deliveries and builds. {words: []string{"deliveries"}, usage: "[--state s] [--repository owner/repo] [--group g] [--all]", @@ -458,7 +458,7 @@ var commands = []command{ return call{"knowledge-base.progress", a}, nil }}, - // Enrolment (the seat mesh-enrolment; `enrol open` is local, in main.go). + // Enrolment (the seat mesh-node-enroller; `enrol open` is local, in main.go). {words: []string{"enrol", "issue"}, usage: " --why [--expires 1h]", does: "a one-time token for a new machine, on the operator's Approve", allowed: []string{"why", "expires"}, build: enrolIssue}, {words: []string{"token", "issue"}, usage: "--new --why ", does: "the same as nox enrol issue", @@ -472,15 +472,15 @@ var commands = []command{ } return enrolIssue(l) }}, - {words: []string{"enrol", "tokens"}, usage: "[]", does: "every open enrolment, or one", build: optArg("mesh-enrolment.tokens", "node")}, + {words: []string{"enrol", "tokens"}, usage: "[]", does: "every open enrolment, or one", build: optArg("mesh-node-enroller.tokens", "node")}, {words: []string{"enrol", "revoke"}, usage: " --why ", does: "end an enrolment at any stage", allowed: []string{"why"}, build: func(l line) (call, error) { if len(l.pos) != 1 || !l.has("why") { return call{}, usageErr("give the node and --why") } - return call{"mesh-enrolment.revoke", map[string]any{"node": l.pos[0], "why": l.flags["why"]}}, nil + return call{"mesh-node-enroller.revoke", map[string]any{"node": l.pos[0], "why": l.flags["why"]}}, nil }}, - {words: []string{"enrol", "again"}, usage: "", does: "raise an enrolment's ask again", build: needArg("mesh-enrolment.again", "node")}, + {words: []string{"enrol", "again"}, usage: "", does: "raise an enrolment's ask again", build: needArg("mesh-node-enroller.again", "node")}, // Any address. {words: []string{"call"}, usage: "
[]", does: "call any seat verb or module tool, e.g. ace/node-service-manager.status", @@ -621,16 +621,16 @@ func numbers(a map[string]any, names ...string) (map[string]any, error) { return a, nil } -// propose is a change of placement with its why. +// propose is a change of assignments with its why. func propose(l line, changes ...map[string]any) (call, error) { if !l.has("why") { - return call{}, usageErr("a change of placement needs --why; the operator is asked to approve it") + return call{}, usageErr("a change of assignments needs --why; the operator is asked to approve it") } list := make([]any, len(changes)) for i, c := range changes { list[i] = c } - return call{"mesh-placement.propose", map[string]any{"why": l.flags["why"], "changes": list}}, nil + return call{"mesh-module-assigner.propose", map[string]any{"why": l.flags["why"], "changes": list}}, nil } func placeModules(op string) func(line) (call, error) { @@ -649,7 +649,7 @@ func placeModules(op string) func(line) (call, error) { func publicDomain(l line) (call, error) { switch { case len(l.pos) == 1 && !l.has("clear"): - return call{"mesh-placement.placement", map[string]any{"node": l.pos[0]}}, nil + return call{"mesh-module-assigner.assignments", map[string]any{"node": l.pos[0]}}, nil case len(l.pos) == 1: return propose(l, map[string]any{"op": "decide", "node": l.pos[0], "field": "public-domain", "value": nil}) case len(l.pos) == 2 && !l.has("clear"): @@ -705,7 +705,7 @@ func enrolIssue(l line) (call, error) { } a := pick(l, "why", "expires") a["node"] = l.pos[0] - return call{"mesh-enrolment.issue", a}, nil + return call{"mesh-node-enroller.issue", a}, nil } func addressCall(address string, rest []string) (call, error) { @@ -718,14 +718,14 @@ func addressCall(address string, rest []string) (call, error) { return call{address, a}, nil } -// renderNodes shapes placement's keys as one line per node: its name, address and site. +// renderNodes shapes the assignments' keys as one line per node: its name, address and site. func renderNodes(text string) string { var p struct { Keys map[string]struct { - Placement struct { + Assignments struct { Decided map[string]any `json:"decided"` Modules []any `json:"modules"` - } `json:"placement"` + } `json:"assignments"` } `json:"keys"` } // The console says after the answer which machine answered it ("answered by ace"): kept, below the lines. @@ -745,8 +745,8 @@ func renderNodes(text string) string { var b strings.Builder for _, n := range names { k := p.Keys[n] - fmt.Fprintf(&b, "%-10s %-15v %-8v %d modules\n", n, orDash(k.Placement.Decided["address"]), - orDash(k.Placement.Decided["site"]), len(k.Placement.Modules)) + fmt.Fprintf(&b, "%-10s %-15v %-8v %d modules\n", n, orDash(k.Assignments.Decided["address"]), + orDash(k.Assignments.Decided["site"]), len(k.Assignments.Modules)) } if after != "" { b.WriteString(after + "\n") diff --git a/modules/mesh-cli/cmd/mesh-cli/enrol.go b/modules/mesh-cli/cmd/mesh-cli/enrol.go index b18a81c82..b04e59a25 100644 --- a/modules/mesh-cli/cmd/mesh-cli/enrol.go +++ b/modules/mesh-cli/cmd/mesh-cli/enrol.go @@ -2,7 +2,7 @@ package main // `nox enrol open`: the operator opens an enrolment token at their own terminal (novox/hq ADR 0587 §6, §8). // -// The seat mesh-enrolment's `tokens` answers the token sealed to the operator key, which only the operator's private key +// The seat mesh-node-enroller's `tokens` answers the token sealed to the operator key, which only the operator's private key // opens; the mesh never holds it. This subcommand is local: it asks nothing of the node-engine's socket and nothing of // the controller or the bus. It reads the sealed token (the base64 of the sealed box) on standard input and the private // key from a file (`--key`, the raw X25519 key in base64 that `operator key make` wrote), opens the box, and prints the @@ -22,7 +22,7 @@ import ( const enrolUsage = `nox enrol open --key -Opens an enrolment token sealed to the operator key (the answer of the seat mesh-enrolment's tokens) and prints the token +Opens an enrolment token sealed to the operator key (the answer of the seat mesh-node-enroller's tokens) and prints the token to type on the new machine. The sealed token is read on standard input; this runs here and asks nothing of the mesh. ` diff --git a/modules/mesh-cli/cmd/mesh-cli/main_test.go b/modules/mesh-cli/cmd/mesh-cli/main_test.go index 3caa08979..2fca7dfd5 100644 --- a/modules/mesh-cli/cmd/mesh-cli/main_test.go +++ b/modules/mesh-cli/cmd/mesh-cli/main_test.go @@ -87,17 +87,17 @@ func TestEachLineCallsItsOwner(t *testing.T) { address string args string }{ - {"node show ace", "mesh-placement.placement", `{"node":"ace"}`}, - {"node public-domain ace", "mesh-placement.placement", `{"node":"ace"}`}, - {"node public-domain ace example.org --why moved", "mesh-placement.propose", `{"changes":[{"field":"public-domain","node":"ace","op":"decide","value":"example.org"}],"why":"moved"}`}, + {"node show ace", "mesh-module-assigner.assignments", `{"node":"ace"}`}, + {"node public-domain ace", "mesh-module-assigner.assignments", `{"node":"ace"}`}, + {"node public-domain ace example.org --why moved", "mesh-module-assigner.propose", `{"changes":[{"field":"public-domain","node":"ace","op":"decide","value":"example.org"}],"why":"moved"}`}, {"plan ace", "mesh-delivery-view.assigner_wants", `{"node":"ace"}`}, {"haves", "mesh-delivery-view.node_haves", `{}`}, {"composition ace plex", "ace/node-engine.composition", `{"module":"plex"}`}, - {"seats", "mesh-placement.placement", `{"seats":"true"}`}, - {"assign ace plex sonarr --why=media", "mesh-placement.propose", `{"changes":[{"module":"plex","node":"ace","op":"place"},{"module":"sonarr","node":"ace","op":"place"}],"why":"media"}`}, - {"unassign ace plex --why gone", "mesh-placement.propose", `{"changes":[{"module":"plex","node":"ace","op":"take-off"}],"why":"gone"}`}, - {"pin ace postgres-database novox postgres --why one", "mesh-placement.propose", `{"changes":[{"node":"ace","op":"pin","provider":{"module":"postgres","node":"novox"},"provision":"postgres-database"}],"why":"one"}`}, - {"unpin ace postgres-database --why back", "mesh-placement.propose", `{"changes":[{"node":"ace","op":"unpin","provision":"postgres-database"}],"why":"back"}`}, + {"seats", "mesh-module-assigner.assignments", `{"seats":"true"}`}, + {"assign ace plex sonarr --why=media", "mesh-module-assigner.propose", `{"changes":[{"module":"plex","node":"ace","op":"place"},{"module":"sonarr","node":"ace","op":"place"}],"why":"media"}`}, + {"unassign ace plex --why gone", "mesh-module-assigner.propose", `{"changes":[{"module":"plex","node":"ace","op":"take-off"}],"why":"gone"}`}, + {"pin ace postgres-database novox postgres --why one", "mesh-module-assigner.propose", `{"changes":[{"node":"ace","op":"pin","provider":{"module":"postgres","node":"novox"},"provision":"postgres-database"}],"why":"one"}`}, + {"unpin ace postgres-database --why back", "mesh-module-assigner.propose", `{"changes":[{"node":"ace","op":"unpin","provision":"postgres-database"}],"why":"back"}`}, {"deliveries --state merged --json", "mesh-delivery.deliveries", `{"state":"merged"}`}, {"delivery novox/mesh-sdk@dff0ce2abb21", "mesh-delivery.show", `{"id":"novox/mesh-sdk@dff0ce2abb21"}`}, {"checks novox/mesh-catalog --number 591", "mesh-delivery.checks", `{"number":"591","repository":"novox/mesh-catalog"}`}, @@ -132,9 +132,9 @@ func TestEachLineCallsItsOwner(t *testing.T) { {"kb ask what holds about the merge window", "knowledge-base.ask", `{"question":"what holds about the merge window"}`}, {"kb get 587 --reasoning", "knowledge-base.get", `{"number":587,"reasoning":true}`}, {"progress 47", "knowledge-base.progress", `{"of":"design 47"}`}, - {"enrol issue new1 --why laptop --expires 2h", "mesh-enrolment.issue", `{"expires":"2h","node":"new1","why":"laptop"}`}, - {"token issue --new new1 --why laptop", "mesh-enrolment.issue", `{"node":"new1","why":"laptop"}`}, - {"enrol tokens", "mesh-enrolment.tokens", `{}`}, + {"enrol issue new1 --why laptop --expires 2h", "mesh-node-enroller.issue", `{"expires":"2h","node":"new1","why":"laptop"}`}, + {"token issue --new new1 --why laptop", "mesh-node-enroller.issue", `{"node":"new1","why":"laptop"}`}, + {"enrol tokens", "mesh-node-enroller.tokens", `{}`}, {"call ace/node-service-manager.status {\"unit\":\"plex\"}", "ace/node-service-manager.status", `{"unit":"plex"}`}, {"ask mesh-catalog catalog_modules", "mesh-catalog.catalog_modules", `{}`}, } { @@ -190,13 +190,13 @@ func TestALineWithNoOwnerOrNotReadCallsNothingAndSaysWhy(t *testing.T) { // What the console says is printed as said: an answer it marks an error on standard error with exit 1, a console that // does not answer as nothing called, a verb's process refused before anything is called; nodes are one line each. func TestAnswersRefusalsAndSilenceAreSaid(t *testing.T) { - got := console(t, map[string]string{"mesh-enrolment.tokens": "!nothing serves seat:mesh-enrolment.tokens"}) + got := console(t, map[string]string{"mesh-node-enroller.tokens": "!nothing serves seat:mesh-node-enroller.tokens"}) if code, out, errOut := runLine("enrol", "tokens"); code != 1 || out != "" || !strings.Contains(errOut, "nothing serves") { t.Errorf("an error answer: exit %d, stdout %q, stderr %q", code, out, errOut) } - placement := `{"keys":{"novox":{"placement":{"decided":{"address":"10.10.0.1","site":"hub"},"modules":[{},{}]}},` + - `"ace":{"placement":{"decided":{"address":"10.10.0.2"},"modules":[{}]}}}}` - console(t, map[string]string{"mesh-placement.placement": placement}) + assignments := `{"keys":{"novox":{"assignments":{"decided":{"address":"10.10.0.1","site":"hub"},"modules":[{},{}]}},` + + `"ace":{"assignments":{"decided":{"address":"10.10.0.2"},"modules":[{}]}}}}` + console(t, map[string]string{"mesh-module-assigner.assignments": assignments}) if code, out, _ := runLine("nodes"); code != 0 || !strings.HasPrefix(out, "ace ") || !strings.Contains(out, "10.10.0.1") || !strings.Contains(out, "2 modules") || !strings.HasSuffix(out, "answered by ace\n") { t.Errorf("nodes: exit %d, %q", code, out) diff --git a/modules/mesh-conditions/cmd/mesh-conditions/hold.go b/modules/mesh-conditions/cmd/mesh-conditions/hold.go index 97be8fedd..f34e5655d 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/hold.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/hold.go @@ -2,7 +2,7 @@ package main // **A module being delivered raises no condition until its delivery ends** (novox/hq ADR 0564 clause 13 and Limits 12; // issue 1952 C7, C8). The keeper reads the assigner's wants and the nodes' haves, and judges a module's delivery on a -// node with mesh-sdk's delivery.MotionOf, as the view does. While it is delivered there, a finding about it there, or +// node with mesh-sdk's motion.MotionOf, as the view does. While it is delivered there, a finding about it there, or // about what it serves, is kept in the state // `held` and raises nothing. When the delivery ends, healthy at the want or failed, what still stands is raised once, // saying it happened during the delivery; what went is dropped with a log line. The hold lasts at most the delivery's @@ -18,9 +18,13 @@ import ( "strings" "time" + engineclient "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + meshwants "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/health" + "git.novox.be/novox/mesh-sdk/go/motion" + + cc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" ) // The module whose delivery holds what it serves beyond their own findings (Limits 12). @@ -32,20 +36,20 @@ type about struct{ Node, Module string } // aboutOf are the modules on nodes a condition's subject is about (Limits 12): a module on a machine, a provider on its // node, a seat of the same name as its module on a machine, and every module's tools on a machine whose tool // runner is delivered. Never the machine itself. -func (k *Keeper) aboutOf(kind, source string, s health.Subject) []about { +func (k *Keeper) aboutOf(kind, source string, s seat.Subject) []about { var out []about parts := strings.Split(s.ID, ".") switch s.Scope { - case health.ScopeModule: + case cc.ScopeModule: if len(parts) >= 2 { out = append(out, about{Node: parts[len(parts)-1], Module: strings.Join(parts[:len(parts)-1], ".")}) } - case health.ScopeProvider: + case cc.ScopeProvider: if len(parts) >= 2 { out = append(out, about{Node: parts[1], Module: parts[0]}) } - case health.ScopeSeat: - // The module the placement says holds the seat on that node, or the seat's own name where none is read. + case cc.ScopeSeat: + // The module the assignments says holds the seat on that node, or the seat's own name where none is read. if len(parts) >= 2 { node, seat := parts[len(parts)-1], strings.Join(parts[:len(parts)-1], ".") module := k.holders[node+"."+seat] @@ -54,7 +58,7 @@ func (k *Keeper) aboutOf(kind, source string, s health.Subject) []about { } out = append(out, about{Node: node, Module: module}) } - case health.ScopeMachine: + case cc.ScopeMachine: // The machine itself (presence, network, resolver, disk) is never held; its tools are, while its tool runner is // delivered. if source == "S11" || kind == "tools-silent" { @@ -65,7 +69,7 @@ func (k *Keeper) aboutOf(kind, source string, s health.Subject) []about { } return out } - if s.Machine != "" && s.Scope != health.ScopeMachine { + if s.Machine != "" && s.Scope != cc.ScopeMachine { out = append(out, about{Node: s.Machine, Module: runtimeModule}) } return out @@ -77,14 +81,14 @@ func splitNodeModule(hk string) (string, string) { } // motion is the delivery of module on node, from its want and have. -func (k *Keeper) motion(node, module string) (delivery.Motion, delivery.Want) { +func (k *Keeper) motion(node, module string) (motion.Motion, motion.Want) { w, wanted := k.wants[node+"."+module] h, has := k.haves[node+"."+module] - return delivery.MotionOf(w, wanted, h, has, "", false), w + return motion.MotionOf(w, wanted, h, has, "", false), w } // holdFor says whether key is held now, and as what. Called with mu held. -func (k *Keeper) holdFor(key string, f *health.Finding, open bool) (held, bool) { +func (k *Keeper) holdFor(key string, f *seat.FindingsByKeyValue, open bool) (held, bool) { if open || f == nil { return held{}, false } @@ -135,7 +139,7 @@ func (k *Keeper) bound(string) time.Duration { if k.Bound > 0 { return k.Bound } - return delivery.DefaultDeliveryBound + return motion.DefaultDeliveryBound } func boundKey(node, module string) string { return node + "." + module } @@ -160,15 +164,15 @@ func (k *Keeper) dropHeld(key, why string) { // Want takes one change of the assigner's wants; Have one of the nodes' haves. Each may end a delivery, which raises or // drops what it held. func (k *Keeper) Want(key string, value []byte, deleted bool) error { - node, module, err := delivery.ParseWantKey(key) - if err != nil { + node, module, ok := meshwants.ParseWantsByWantNodeModuleKey(key) + if !ok { return nil // not a want of a module on a node (a held fingerprint's key) } k.mu.Lock() k.init() if deleted { delete(k.wants, node+"."+module) - } else if w, err := delivery.DecodeWant(value); err == nil { + } else if w, err := decodeWant(value); err == nil { k.wants[node+"."+module] = w } else { k.Logf("the want %s is not readable: %v", key, err) @@ -179,7 +183,7 @@ func (k *Keeper) Want(key string, value []byte, deleted bool) error { // Have takes one change of a node's have. func (k *Keeper) Have(key string, value []byte, deleted bool) error { - node, module, isNode, err := delivery.ParseHaveKey(key) + node, module, isNode, err := engineclient.ParseHaveKey(key) if err != nil || isNode { return nil } @@ -187,8 +191,8 @@ func (k *Keeper) Have(key string, value []byte, deleted bool) error { k.init() if deleted { delete(k.haves, node+"."+module) - } else if h, err := delivery.DecodeHave(value); err == nil { - k.haves[node+"."+module] = h + } else if h, err := engineclient.DecodeHave(value); err == nil { + k.haves[node+"."+module] = motion.Have{FP: h.Fp, Composed: h.Composed, Phase: h.Phase, Health: h.Health, Detail: h.Detail} } else { k.Logf("the have %s is not readable: %v", key, err) } @@ -196,25 +200,25 @@ func (k *Keeper) Have(key string, value []byte, deleted bool) error { return k.judge(node, module, "") } -// Placement takes one change of the placement: which module holds which seat on a node. The keys are read in either -// form, a node key and its module keys or the form before (delivery.PlacementKeys; research 072 row 37). -func (k *Keeper) Placement(key string, value []byte, deleted bool) { +// Assignments takes one change of the assignments: which module holds which seat on a node. The keys are a node key and +// its module keys (assignerclient.Keys; research 072 row 37). +func (k *Keeper) Assignments(key string, value []byte, deleted bool) { k.mu.Lock() defer k.mu.Unlock() k.init() - node, err := k.placement.Put(key, value, 0, deleted) + node, err := k.assignments.Put(key, value, 0, deleted) if node == "" { return } if err != nil { - k.Logf("the placement of %s is not readable as it stands, so its seats' holders are kept as last read: %v", node, err) + k.Logf("the assignments of %s is not readable as it stands, so its seats' holders are kept as last read: %v", node, err) } for hk := range k.holders { if strings.HasPrefix(hk, node+".") { delete(k.holders, hk) } } - p, _, ok := k.placement.Of(node) + p, _, ok := k.assignments.Of(node) if !ok { return } @@ -318,3 +322,12 @@ func (k *Keeper) restoreHeld(key string, value []byte) { k.held[key] = h k.mu.Unlock() } + +// decodeWant reads a want of a module on a node as far as a delivery's motion needs it. +func decodeWant(value []byte) (motion.Want, error) { + var w meshwants.WantsByWantNodeModuleValue + if err := json.Unmarshal(value, &w); err != nil { + return motion.Want{}, err + } + return motion.Want{FP: w.Fp, Composed: w.Composed, Removed: w.Removed}, nil +} diff --git a/modules/mesh-conditions/cmd/mesh-conditions/hold_test.go b/modules/mesh-conditions/cmd/mesh-conditions/hold_test.go index ef555a03b..2bd970e4a 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/hold_test.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/hold_test.go @@ -5,8 +5,13 @@ import ( "strings" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/health" + assignerclient "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + engineclient "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + mesh "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + cc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + meshwants "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" + "git.novox.be/novox/mesh-sdk/go/motion" ) // The fingerprints and composition of mesh-controller on novox, copied from its have (mesh-delivery-view.node_haves, @@ -27,11 +32,11 @@ const ( // held key as held. func TestADeliveryHoldsItsOwnFindingsUntilItEnds(t *testing.T) { want := func(fp, composed string) []byte { - raw, _ := json.Marshal(delivery.Want{V: delivery.Version, FP: fp, Composed: composed}) + raw, _ := json.Marshal(meshwants.WantsByWantNodeModuleValue{V: 1, Fp: fp, Composed: composed}) return raw } - have := func(fp, composed string, phase delivery.Phase, h delivery.Health) []byte { - raw, _ := json.Marshal(delivery.Have{V: delivery.Version, FP: fp, Composed: composed, Phase: phase, Health: h}) + have := func(fp, composed string, phase, h string) []byte { + raw, _ := json.Marshal(nodeengine.HaveByNodeModuleValue{V: 1, Fp: fp, Composed: composed, Phase: phase, Health: h}) return raw } for _, c := range []struct { @@ -42,22 +47,22 @@ func TestADeliveryHoldsItsOwnFindingsUntilItEnds(t *testing.T) { said string line string }{ - {name: "gone by its end", end: have(ctrlNext, ctrlNextComp, delivery.PhaseRunning, delivery.HealthOK)}, - {name: "still standing at its end", end: have(ctrlNext, ctrlNextComp, delivery.PhaseRunning, delivery.HealthOK), + {name: "gone by its end", end: have(ctrlNext, ctrlNextComp, motion.PhaseRunning, engineclient.HealthOK)}, + {name: "still standing at its end", end: have(ctrlNext, ctrlNextComp, motion.PhaseRunning, engineclient.HealthOK), standing: true, said: "raised module.mesh-controller.novox.unhealthy", line: "during the delivery of version 0d1e2f3a4b5c"}, {name: "past its bound", standing: true, said: "raised module.mesh-controller.novox.unhealthy", line: "did not end within"}, - {name: "failed", end: have(ctrlNext, ctrlNextComp, delivery.PhaseFailed, delivery.HealthFailed), standing: true, + {name: "failed", end: have(ctrlNext, ctrlNextComp, motion.PhaseFailed, engineclient.HealthFailed), standing: true, said: "raised module.mesh-controller.novox.unhealthy", line: "as the delivery of version 0d1e2f3a4b5c"}, } { r := newRig() _ = r.k.Want("want.novox.mesh-controller", want(ctrlRunning, ctrlComposed), false) - _ = r.k.Have("novox.mesh-controller", have(ctrlRunning, ctrlComposed, delivery.PhaseRunning, delivery.HealthOK), false) + _ = r.k.Have("novox.mesh-controller", have(ctrlRunning, ctrlComposed, motion.PhaseRunning, engineclient.HealthOK), false) // The delivery starts: the assigner wants the next fingerprint; the node applies it. _ = r.k.Want("want.novox.mesh-controller", want(ctrlNext, ctrlNextComp), false) - _ = r.k.Have("novox.mesh-controller", have(ctrlNext, ctrlNextComp, delivery.PhaseApplying, delivery.HealthUnknown), false) - own, ownRaw := finding(t, watchman, health.ScopeModule, "mesh-controller.novox", "unhealthy", "health", health.Warning, r.now) - other, otherRaw := finding(t, watchman, health.ScopeModule, "postgres.novox", "unhealthy", "health", health.Warning, r.now) - machine, machineRaw := finding(t, watchman, health.ScopeMachine, "novox", "silent", "S1", health.Urgent, r.now) + _ = r.k.Have("novox.mesh-controller", have(ctrlNext, ctrlNextComp, motion.PhaseApplying, engineclient.HealthUnknown), false) + own, ownRaw := finding(t, watchman, cc.ScopeModule, "mesh-controller.novox", "unhealthy", "health", cc.Warning, r.now) + other, otherRaw := finding(t, watchman, cc.ScopeModule, "postgres.novox", "unhealthy", "health", cc.Warning, r.now) + machine, machineRaw := finding(t, watchman, cc.ScopeMachine, "novox", "silent", "S1", cc.Urgent, r.now) for _, f := range []struct { key string raw []byte @@ -67,7 +72,7 @@ func TestADeliveryHoldsItsOwnFindingsUntilItEnds(t *testing.T) { } } // The delivery's bound is its own key's time to live, the module's bound (the default where it names none). - if r.st.ttls["bounds/novox.mesh-controller"] != delivery.DefaultDeliveryBound { + if r.st.ttls["bounds/novox.mesh-controller"] != motion.DefaultDeliveryBound { t.Errorf("%s: the bound key lives %s", c.name, r.st.ttls["bounds/novox.mesh-controller"]) } if got := r.changes(); got != "raised "+other+", raised "+machine { @@ -80,7 +85,7 @@ func TestADeliveryHoldsItsOwnFindingsUntilItEnds(t *testing.T) { if c.end != nil { _ = r.k.Have("novox.mesh-controller", c.end, false) } else { - r.now = r.now.Add(delivery.DefaultDeliveryBound) + r.now = r.now.Add(motion.DefaultDeliveryBound) _ = r.k.BoundPassed("novox.mesh-controller") } if got := r.changes(); got != c.said { @@ -93,26 +98,21 @@ func TestADeliveryHoldsItsOwnFindingsUntilItEnds(t *testing.T) { t.Errorf("%s: something is still held: %+v", c.name, r.k.Held()) } } - // A seat's finding is about the module the placement says holds the seat on that node (the lead, 2026-10-29): held - // while the assigner is delivered on ace, though the seat is named mesh-placement. + // A seat's finding is about the module the assignments says holds the seat on that node (the lead, 2026-10-29): held + // while the assigner is delivered on ace, though the seat is named mesh-module-assigner. r := newRig() - // Research 072 row 37: the placement in its keys per module and node; a stale legacy key beside them is not read. - stale, err := delivery.EncodePlacement(delivery.Placement{Node: "ace"}) + // Research 072 row 37: the assignments in its keys per module and node. + placed, err := assignerclient.AssignmentsValues(mesh.Assignments{Node: "ace", Modules: []mesh.PlacedModule{{Module: "mesh-delivery-assigner"}}, + Holds: []mesh.Holding{{Claim: "mesh-module-assigner", Scope: "mesh", Module: "mesh-delivery-assigner"}}}) if err != nil { t.Fatal(err) } - r.k.Placement("placement.ace", stale, false) - placed, err := delivery.PlacementValues(delivery.Placement{Node: "ace", Modules: []delivery.PlacedModule{{Module: "mesh-delivery-assigner"}}, - Holds: []delivery.Holding{{Claim: "mesh-placement", Scope: "mesh", Module: "mesh-delivery-assigner"}}}) - if err != nil { - t.Fatal(err) - } - r.k.Placement("ace.mesh-delivery-assigner", placed["ace.mesh-delivery-assigner"], false) - r.k.Placement("ace.node", placed["ace.node"], false) + r.k.Assignments("ace.mesh-delivery-assigner", placed["ace.mesh-delivery-assigner"], false) + r.k.Assignments("ace.node", placed["ace.node"], false) _ = r.k.Want("want.ace.mesh-delivery-assigner", want(ctrlNext, ctrlNextComp), false) - _ = r.k.Have("ace.mesh-delivery-assigner", have(ctrlRunning, ctrlComposed, delivery.PhaseRunning, delivery.HealthOK), false) - seat, seatRaw := finding(t, selfCheck, health.ScopeSeat, "mesh-placement.ace", "holder-silent", "D3", health.Warning, r.now) - _ = r.k.Finding(selfCheck, seat, seatRaw, false) + _ = r.k.Have("ace.mesh-delivery-assigner", have(ctrlRunning, ctrlComposed, motion.PhaseRunning, engineclient.HealthOK), false) + seatKey, seatRaw := finding(t, selfCheck, cc.ScopeSeat, "mesh-module-assigner.ace", "holder-silent", "D3", cc.Warning, r.now) + _ = r.k.Finding(selfCheck, seatKey, seatRaw, false) if got := r.changes(); got != "" || len(r.k.Held()) != 1 || r.k.Held()[0].Module != "mesh-delivery-assigner" { t.Errorf("a seat's finding while its holder is delivered: said %q, held %+v", got, r.k.Held()) } diff --git a/modules/mesh-conditions/cmd/mesh-conditions/homes.go b/modules/mesh-conditions/cmd/mesh-conditions/homes.go index 86072e9f2..dad8d9eb5 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/homes.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/homes.go @@ -93,6 +93,46 @@ var Homes = map[string]Home{ "asks-undelivered": {Source: "asks", Controller: true, Observer: messenger}, // The seat mesh-artifact-sweep (L7): an image a want names is missing from the store, artifact-store-tools' finding. "image-missing": {Source: "S25", Controller: true, Observer: artifactStore}, + // mesh-config's own (design 32): a rename it refused, a rename window or a retirement held seven days by a node, a + // retirement asked twice and not answered. + "rename-refused": {Source: "config", Observer: configModule}, + "rename-window-held": {Source: "config", Observer: configModule}, + "retirement-held": {Source: "config", Observer: configModule}, + "retiring-unanswered": {Source: "config", Observer: configModule}, + // A reader whose owner does not answer its first view past two minutes (ADR 0615 §6): written by each reader in its own + // findings (mesh-config, mesh-vault), so the kind has no one observer. + "first-view-unanswered": {Source: "views", Why: "every reader of the indexer's or the assigner's state writes its own"}, + // An owner's views event too large to say (ADR 0615 §5): written by each owner of a views family in its own findings + // (the indexer, the assigner), so the kind has no one observer. + "view-too-large": {Source: "views", Why: "every owner of a views family writes its own"}, + // An owner's change not said on its views family past its bound (mesh-sdk views.UnsaidFinding, id .): + // written by each owner of a views family in its own findings (the indexer, the assigner), so no one observer. + "view-unsaid": {Source: "views", Why: "every owner of a views family writes its own"}, + // The builder's own (design 51): a build that ended failed, a floor whose holder said nothing past its bound, an old + // form held seven days by a counted node-engine, a tag's removal asked and not done. + "build-failed": {Source: "builder", Observer: builder}, + "floor-silent": {Source: "builder", Observer: builder}, + "old-form-held": {Source: "builder", Observer: builder}, + "tag-removal-not-done": {Source: "builder", Observer: builder}, + // The builder's publishing (design 51): a holder refused the publish, or said nothing past its bound (about the name); + // a holder's outcome the builder cannot read (about the seat). + "publish-refused": {Source: "builder", Observer: builder}, + "publish-unanswered": {Source: "builder", Observer: builder}, + "seat-outcome-unread": {Source: "builder", Observer: builder}, + // A consumer of a delivery part that stopped hearing an event family (ADR 0615 §5), cleared when it binds again: written + // by each of the indexer, the assigner, the builder and the watchman in its own findings, so the kind has no one observer. + "family-not-heard": {Source: "views", Why: "every delivery part whose consumer stopped hearing writes its own"}, + // The indexer's own (design 26, ADR 0625): a seat definition that does not parse, generated types out of step with + // their definition, one seat defined by two repositories' trunks, a manifest accepted and not said. + "seat-definition-unparsed": {Source: "indexer", Observer: indexer}, + "generated-types-out-of-step": {Source: "indexer", Observer: indexer}, + "seat-defined-twice": {Source: "indexer", Observer: indexer}, + "accepted-unsaid": {Source: "indexer", Observer: indexer}, + // A module's new entry held from the whole mesh past its bound while a counted node-engine lacks the reading it + // needs (ADR 0632 §2), cleared when the hold moves or is dropped. + "manifest-held": {Source: "indexer", Observer: indexer}, + // The package seats' holder (design 32): the forge refused the packages account's write token. + "packages-token-refused": {Source: "gitea", Observer: gitea}, // The kinds that end with the controller and are carried until its lease write (clauses 4, 9). "controller-deaf": {Source: "S4", Controller: true, Carried: true}, "lease-lost": {Source: "S12", Controller: true, Carried: true}, @@ -155,6 +195,12 @@ const ( provisioner = "mesh-provisioner" messenger = "messenger" artifactStore = "artifact-store-tools" + configModule = "mesh-config" + vaultModule = "mesh-vault" + builder = "mesh-delivery-builder" + indexer = "mesh-delivery-indexer" + gitea = "gitea" + enrolment = "mesh-enrolment" ) // homeOf is the home of a condition's kind, and whether the table has one. diff --git a/modules/mesh-conditions/cmd/mesh-conditions/keeper.go b/modules/mesh-conditions/cmd/mesh-conditions/keeper.go index e72cd4269..178234acb 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/keeper.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/keeper.go @@ -5,7 +5,7 @@ package main // the nodes' haves for the hold; nothing of the controller (its `conditions` are no longer read, issue 774, and its lease // turns nothing, issue 1126: the controller ended by its unassignment). From all of them it derives, per key, the // condition that should be open, and writes the difference by compare-and-set, -// saying each transition as the controller says it (go/health) and keeping it in `history`. Nothing clears a condition +// saying each transition as the seat's contract says it and keeping it in `history`. Nothing clears a condition // but its finding going (ADR 0231 §14). import ( @@ -17,17 +17,34 @@ import ( "sync" "time" + assignerclient "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + mesh "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/health" + "git.novox.be/novox/mesh-sdk/go/motion" + + cc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + meshhealers "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhealers" +) + +// The modules whose states the keeper reads by name: the holders of the health seats (their seats' names are the +// contracts'; the module is the holder). +const ( + watchmanModule = "mesh-delivery-watchman" + selfCheckModule = "mesh-self-check" + healersModule = "mesh-healers" + // The holders of the assignments and the catalog, whose views and findings the keeper follows (the manifest test holds + // these equal to the manifest's `consumes` and `invokes`). + assignerModule = "mesh-delivery-assigner" + indexerModule = "mesh-delivery-indexer" ) // The module, its seat, and the states it keeps. const ( - Module = health.KeeperModule - Seat = "mesh-conditions" + Module = "mesh-conditions" + Seat = seat.Seat - ConditionsState = health.ConditionsState + ConditionsState = seat.StateConditions HistoryState = "history" HeldState = "held" BoundsState = "bounds" @@ -39,16 +56,24 @@ const HistoryKeptFor = 90 * 24 * time.Hour // Observers are the modules whose `findings` the keeper reads (clause 2; ADR 0565 §8 for the assigner's; the lead's // table of 2026-10-29 for the provisioner's and the router's; ADR 0571 clause 2 for the seat artifact-store's holder, // which observes S25, image-missing). -var Observers = []string{health.WatchmanModule, health.SelfCheckModule, health.HealersModule, delivery.AssignerModule, - "mesh-provisioner", "messenger", artifactStore} +var Observers = []string{watchmanModule, selfCheckModule, healersModule, assignerModule, + "mesh-provisioner", "messenger", artifactStore, configModule, vaultModule, + // The readers of the indexer's and the assigner's state say a first view unanswered past its bound in their own findings + // (ADR 0615 §6). + "mesh-watcher", "minio", "nfs-server", gitea, "mesh-build-go", + // The builder's, the indexer's (asked of its read tool and followed by its views events, ADR 0615 §1) and the other + // readers' of the indexer's and the assigner's state (the registry milestone's join). + builder, indexer, "mesh-delivery-checker", "mesh-delivery-teller", "mesh-delivery-view", enrolment, + // The tool runner's and claude-code's own first views (views.UnansweredFinding in their findings, ADR 0615 §6). + "node-tools", "claude-code"} // The other states it reads, as `.`. const ( - AttemptsRead = health.HealersModule + "." + health.AttemptsState - WantsRead = delivery.AssignerModule + ".wants" - // PlacementRead is the mesh-placement seat's placement, read for which module holds a seat on a node (hold.go; the + AttemptsRead = healersModule + "." + meshhealers.StateAttempts + WantsRead = assignerModule + ".wants" + // AssignmentsRead is the mesh-module-assigner seat's assignments, read for which module holds a seat on a node (hold.go; the // lead, 2026-10-29, for ADR 0564 clause 2's inputs). - PlacementRead = delivery.PlacementRead + AssignmentsRead = assignerModule + "." + mesh.StateAssignments HavesRead = "node-engine.have" ConditionWrites = 8 ) @@ -79,35 +104,35 @@ type clearedMemo struct { at time.Time count int began time.Time - gaps []health.Gap - silenced *health.Silence - tried []health.Tried + gaps []Gap + silenced *Silence + tried []Tried } // Keeper is the condition keeper's state and its writes. type Keeper struct { Store Store // Say publishes one event on the seat; History keeps one transition. - Say func(e health.Event) error + Say func(e Event) error Now func() time.Time Logf func(format string, a ...any) // Homes says, by kind, where each kind the controller raised lives now (homes.go); Homes when nil. Homes map[string]Home - // Bound is a delivery's hold bound; delivery.DefaultDeliveryBound when zero (hold.go). + // Bound is a delivery's hold bound; motion.DefaultDeliveryBound when zero (hold.go). Bound time.Duration mu sync.Mutex - findings map[string]map[string]health.Finding + findings map[string]map[string]seat.FindingsByKeyValue blind map[string]string - attempts map[string]health.Attempt - wants map[string]delivery.Want - haves map[string]delivery.Have - // holders are, by `.`, the module the placement says holds the seat there. + attempts map[string]meshhealers.AttemptsByAttemptValue + wants map[string]motion.Want + haves map[string]motion.Have + // holders are, by `.`, the module the assignments says holds the seat there. holders map[string]string - // placement is the placement's keys as heard, assembled per node. - placement *delivery.PlacementKeys - stuck map[string]string - held map[string]held + // assignments is the assignments' keys as heard, assembled per node. + assignments *assignerclient.Keys + stuck map[string]string + held map[string]held // passed are the deliveries whose bound passed, by node and module, at the fingerprint wanted then; outOfHold the // line a key raised out of a hold says (hold.go). passed map[string]string @@ -118,12 +143,12 @@ type Keeper struct { func (k *Keeper) init() { if k.findings == nil { - k.findings, k.blind, k.attempts = map[string]map[string]health.Finding{}, map[string]string{}, - map[string]health.Attempt{} - k.wants, k.haves, k.stuck, k.held, k.cleared = map[string]delivery.Want{}, map[string]delivery.Have{}, + k.findings, k.blind, k.attempts = map[string]map[string]seat.FindingsByKeyValue{}, map[string]string{}, + map[string]meshhealers.AttemptsByAttemptValue{} + k.wants, k.haves, k.stuck, k.held, k.cleared = map[string]motion.Want{}, map[string]motion.Have{}, map[string]string{}, map[string]held{}, map[string]clearedMemo{} k.passed, k.outOfHold, k.holders = map[string]string{}, map[string]string{}, map[string]string{} - k.placement = delivery.NewPlacementKeys() + k.assignments = assignerclient.NewKeys() } if k.Now == nil { k.Now = time.Now @@ -139,12 +164,12 @@ func (k *Keeper) Finding(observer, key string, value []byte, deleted bool) error k.mu.Lock() k.init() if k.findings[observer] == nil { - k.findings[observer] = map[string]health.Finding{} + k.findings[observer] = map[string]seat.FindingsByKeyValue{} } if deleted { delete(k.findings[observer], key) } else { - f, err := health.DecodeFinding(value) + f, err := decodeFinding(value) if err != nil { k.mu.Unlock() k.Logf("%s's finding %s is not one the keeper can raise, so it is left as it stood: %v", observer, key, err) @@ -186,7 +211,7 @@ func (k *Keeper) Attempt(key string, value []byte, deleted bool) error { if deleted { delete(k.attempts, key) } else { - var a health.Attempt + var a meshhealers.AttemptsByAttemptValue if err := json.Unmarshal(value, &a); err != nil || a.Condition == "" { k.mu.Unlock() k.Logf("the healers' attempt %s is not readable, so it is left out: %v", key, err) @@ -229,8 +254,8 @@ func (k *Keeper) EndCarried() (int, error) { if err != nil || !found { continue } - var was health.Condition - if json.Unmarshal(raw, &was) != nil { + was, err := decodeCondition(raw) + if err != nil { continue } if err := k.clear(c.Key, was, rev, k.Now().UTC(), endedWhy); err != nil { @@ -273,7 +298,7 @@ func (k *Keeper) settleAll(keys []string) error { // wanted is what the inputs say key should be: a finding of an observer. Nil when nothing says it; nothing is carried // from the controller's bucket (issue 1126). -func (k *Keeper) wanted(key string) *health.Finding { +func (k *Keeper) wanted(key string) *seat.FindingsByKeyValue { observers := make([]string, 0, len(k.findings)) for o := range k.findings { observers = append(observers, o) @@ -294,9 +319,9 @@ func (k *Keeper) settleOnce(key string) error { if err != nil { return fmt.Errorf("reading the condition %s: %w", key, err) } - var was health.Condition + var was Condition if open { - if err := json.Unmarshal(raw, &was); err != nil { + if was, err = decodeCondition(raw); err != nil { return fmt.Errorf("the condition %s on the bus cannot be read: %w", key, err) } } @@ -323,9 +348,9 @@ func (k *Keeper) settleOnce(key string) error { return nil } if open { - _, err = k.Store.Update(ConditionsState, key, next, rev) + _, err = k.Store.Update(ConditionsState, key, next.Stored(), rev) } else { - _, err = k.Store.Create(ConditionsState, key, next) + _, err = k.Store.Create(ConditionsState, key, next.Stored()) } if err != nil { return err @@ -334,29 +359,29 @@ func (k *Keeper) settleOnce(key string) error { delete(k.cleared, key) k.mu.Unlock() for _, e := range events { - e.Condition, e.At, e.Event = next, now, health.EventFor(e.Change) - e.Show = health.ShowKeeper(next.Key) + e.Condition, e.At, e.Event = next, now, cc.EventFor(e.Change) + e.Show = cc.ShowKeeper(next.Key) k.tell(e) } return nil } // derive is the condition key should be now, from what was open and what the inputs say. -func (k *Keeper) derive(key string, was health.Condition, open bool, f *health.Finding, now time.Time) health.Condition { - var c health.Condition +func (k *Keeper) derive(key string, was Condition, open bool, f *seat.FindingsByKeyValue, now time.Time) Condition { + var c Condition switch { case open: c = was k.fromFinding(&c, *f, now) default: - c = health.Condition{Key: key, Raised: now, Count: 1, Resolver: health.ResolverSelf} + c = Condition{Key: key, Raised: now, Count: 1, Resolver: cc.ResolverSelf} k.fromFinding(&c, *f, now) k.mu.Lock() - if before, ok := k.cleared[key]; ok && now.Sub(before.at) <= health.ReopenWithin { + if before, ok := k.cleared[key]; ok && now.Sub(before.at) <= cc.ReopenWithin { c.Count = before.count + 1 c.First = before.began - c.Gaps = append(append([]health.Gap(nil), before.gaps...), health.Gap{Cleared: before.at, Reopened: now}) - if over := len(c.Gaps) - health.KeptGaps; over > 0 { + c.Gaps = append(append([]Gap(nil), before.gaps...), Gap{Cleared: before.at, Reopened: now}) + if over := len(c.Gaps) - cc.KeptGaps; over > 0 { c.First, c.Gaps = c.Gaps[over-1].Reopened, c.Gaps[over:] } if before.silenced != nil && now.Before(before.silenced.Until) { @@ -382,37 +407,37 @@ func (k *Keeper) derive(key string, was health.Condition, open bool, f *health.F // fromFinding is what the condition says of its finding: its words, subject, severity and source, and its evidence // newest first (clause 2). A healer's escalation is not undone by the finding saying a warning again. -func (k *Keeper) fromFinding(c *health.Condition, f health.Finding, now time.Time) { +func (k *Keeper) fromFinding(c *Condition, f seat.FindingsByKeyValue, now time.Time) { escalated := c.Escalated() c.Kind, c.Subject, c.Summary, c.Source = f.Kind, f.Subject, f.Summary, f.Source c.Headline, c.Explanation, c.Resolved, c.Needs, c.Actions = f.Headline, f.Explanation, f.Resolved, f.Needs, f.Actions - if !(escalated && f.Severity != health.Urgent) { + if !(escalated && f.Severity != cc.Urgent) { c.Severity = f.Severity } if !escalated { c.Resolver = f.Resolver if c.Resolver == "" { - c.Resolver = health.ResolverSelf + c.Resolver = cc.ResolverSelf } } if len(f.Evidence) > 0 { - c.Evidence = append([]health.Evidence(nil), f.Evidence...) + c.Evidence = append([]seat.Evidence(nil), f.Evidence...) c.LastObserved = f.Evidence[0].At } else if c.Evidence == nil { - c.Evidence = []health.Evidence{{At: f.At, Said: f.Summary}} + c.Evidence = []seat.Evidence{{At: f.At, Said: f.Summary}} c.LastObserved = f.At } c.Observations = len(c.Evidence) if c.Actions == nil { - c.Actions = []health.Action{} + c.Actions = []seat.Action{} } _ = now } // overlay writes the healers' attempts into the condition (clause 7); nothing of the controller's bucket (issue 1126). -func (k *Keeper) overlay(c *health.Condition, now time.Time) { +func (k *Keeper) overlay(c *Condition, now time.Time) { k.mu.Lock() - var mine []health.Attempt + var mine []meshhealers.AttemptsByAttemptValue for _, a := range k.attempts { if a.Condition == c.Key { mine = append(mine, a) @@ -421,25 +446,25 @@ func (k *Keeper) overlay(c *health.Condition, now time.Time) { k.mu.Unlock() sort.Slice(mine, func(i, j int) bool { return mine[i].Begun.Before(mine[j].Begun) }) for _, a := range mine { - t := a.Tried() + t := triedOf(a) if !containsTried(c.Tried, t) { c.Tried = append(c.Tried, t) } switch a.Outcome { - case health.OutcomeEscalated: - c.Resolver, c.Severity = health.ResolverOperator, health.Urgent + case meshhealers.AttemptsByAttemptValueOutcomeEscalated: + c.Resolver, c.Severity = cc.ResolverOperator, cc.Urgent default: - if c.Resolver != health.ResolverOperator { - c.Resolver = health.ResolverHealer(a.Healer) + if c.Resolver != cc.ResolverOperator { + c.Resolver = cc.ResolverHealer(a.Healer) } } } - if len(c.Tried) > health.KeptTried { - c.Tried = c.Tried[len(c.Tried)-health.KeptTried:] + if len(c.Tried) > cc.KeptTried { + c.Tried = c.Tried[len(c.Tried)-cc.KeptTried:] } } -func containsTried(list []health.Tried, t health.Tried) bool { +func containsTried(list []Tried, t Tried) bool { for _, x := range list { if x.At.Equal(t.At) && x.By == t.By && x.What == t.What { return true @@ -450,47 +475,47 @@ func containsTried(list []health.Tried, t health.Tried) bool { // transitions are the events between what was open and what is now, in the controller's order: raised or reopened; // else severity, resolver, silence and words changes. An observation that changes none of these says nothing. -func transitions(was, now health.Condition, open bool) []health.Event { +func transitions(was, now Condition, open bool) []Event { if !open { - change := health.ChangeRaised + change := cc.ChangeRaised if now.Count > 1 { - change = health.ChangeReopened + change = cc.ChangeReopened } - return []health.Event{{Change: change}} + return []Event{{Change: change}} } - var out []health.Event + var out []Event if was.Severity != now.Severity { - out = append(out, health.Event{Change: health.ChangeSeverity, Was: string(was.Severity)}) + out = append(out, Event{Change: cc.ChangeSeverity, Was: string(was.Severity)}) } if was.Resolver != now.Resolver { - out = append(out, health.Event{Change: health.ChangeResolver, Was: was.Resolver}) + out = append(out, Event{Change: cc.ChangeResolver, Was: was.Resolver}) } switch { case was.Silenced == nil && now.Silenced != nil: - out = append(out, health.Event{Change: health.ChangeSilenced, Why: now.Silenced.Why}) + out = append(out, Event{Change: cc.ChangeSilenced, Why: now.Silenced.Why}) case was.Silenced != nil && now.Silenced == nil: - out = append(out, health.Event{Change: health.ChangeUnsilenced}) + out = append(out, Event{Change: cc.ChangeUnsilenced}) } if was.Needs != now.Needs || !sameActions(was.Actions, now.Actions) { - out = append(out, health.Event{Change: health.ChangeWords, Was: was.Needs}) + out = append(out, Event{Change: cc.ChangeWords, Was: was.Needs}) } return out } -func sameActions(a, b []health.Action) bool { +func sameActions(a, b []seat.Action) bool { x, _ := json.Marshal(a) y, _ := json.Marshal(b) return string(x) == string(y) || (len(a) == 0 && len(b) == 0) } -func sameBody(a, b health.Condition) bool { +func sameBody(a, b Condition) bool { x, _ := json.Marshal(a) y, _ := json.Marshal(b) return string(x) == string(y) } // clear removes an open condition whose finding went, and says it (clause 2). -func (k *Keeper) clear(key string, was health.Condition, rev uint64, now time.Time, why string) error { +func (k *Keeper) clear(key string, was Condition, rev uint64, now time.Time, why string) error { // A delete at the revision read: a write that moved it meanwhile is read again. if _, err := k.Store.Update(ConditionsState, key, json.RawMessage("null"), rev); err != nil { return err @@ -502,19 +527,19 @@ func (k *Keeper) clear(key string, was health.Condition, rev uint64, now time.Ti k.cleared[key] = clearedMemo{at: now, count: was.Count, began: was.Began(), gaps: was.Gaps, silenced: was.Silenced, tried: was.Tried} k.mu.Unlock() cleared := now - k.tell(health.Event{Condition: was, Event: health.EventCleared, At: now, Change: health.ChangeCleared, Why: why, - Cleared: &cleared, Show: health.ShowKeeper(was.Key)}) + k.tell(Event{Condition: was, Event: seat.EventConditionCleared, At: now, Change: cc.ChangeCleared, Why: why, + Cleared: &cleared, Show: cc.ShowKeeper(was.Key)}) return nil } // tell keeps the transition in history and says it on the seat. A transition not said is said in the log: the condition // and its history still hold it. -func (k *Keeper) tell(e health.Event) { +func (k *Keeper) tell(e Event) { k.mu.Lock() k.seq++ hk := fmt.Sprintf("%020d-%06d", e.At.UnixNano(), k.seq) k.mu.Unlock() - if _, err := k.Store.Create(HistoryState, hk, e); err != nil { + if _, err := k.Store.Create(HistoryState, hk, e.Said()); err != nil { k.Logf("the %s of %s could not be kept in history: %v", e.Change, e.Key, err) } if k.Say == nil { @@ -526,12 +551,12 @@ func (k *Keeper) tell(e health.Event) { } // Open is every open condition, ordered as `conditions` says them. -func (k *Keeper) Open() ([]health.Condition, error) { +func (k *Keeper) Open() ([]Condition, error) { keys, err := k.Store.Keys(ConditionsState) if err != nil { return nil, err } - var out []health.Condition + var out []Condition for _, key := range keys { raw, _, found, err := k.Store.Get(ConditionsState, key) if err != nil { @@ -540,13 +565,13 @@ func (k *Keeper) Open() ([]health.Condition, error) { if !found || string(raw) == "null" { continue } - var c health.Condition - if err := json.Unmarshal(raw, &c); err != nil { + c, err := decodeCondition(raw) + if err != nil { return nil, fmt.Errorf("the condition %s on the bus cannot be read: %w", key, err) } out = append(out, c) } - health.Order(out) + order(out) return out, nil } @@ -580,35 +605,35 @@ func (k *Keeper) Resync() (int, error) { // Silence stops a condition's messages for a while, a hand act with why (clause 3, Limits 1): kept as its transition in // history, naming who and why. -func (k *Keeper) Silence(key string, d time.Duration, by, why string) (health.Condition, error) { +func (k *Keeper) Silence(key string, d time.Duration, by, why string) (Condition, error) { if strings.TrimSpace(why) == "" { - return health.Condition{}, errors.New("a silence says why") + return Condition{}, errors.New("a silence says why") } - if d <= 0 || d > health.MaxSilence { - return health.Condition{}, fmt.Errorf("a silence lasts more than nothing and at most %s, not %s", health.MaxSilence, d) + if d <= 0 || d > cc.MaxSilence { + return Condition{}, fmt.Errorf("a silence lasts more than nothing and at most %s, not %s", cc.MaxSilence, d) } for i := 0; i < ConditionWrites; i++ { raw, rev, found, err := k.Store.Get(ConditionsState, key) if err != nil { - return health.Condition{}, err + return Condition{}, err } if !found || string(raw) == "null" { - return health.Condition{}, fmt.Errorf("no condition %s is open", key) + return Condition{}, fmt.Errorf("no condition %s is open", key) } - var c health.Condition - if err := json.Unmarshal(raw, &c); err != nil { - return health.Condition{}, err + c, err := decodeCondition(raw) + if err != nil { + return Condition{}, err } now := k.Now().UTC() - c.Silenced = &health.Silence{Until: now.Add(d), By: by, Why: why, Since: now} - if _, err := k.Store.Update(ConditionsState, key, c, rev); errors.Is(err, stdio.ErrChanged) { + c.Silenced = &Silence{Until: now.Add(d), By: by, Why: why, Since: now} + if _, err := k.Store.Update(ConditionsState, key, c.Stored(), rev); errors.Is(err, stdio.ErrChanged) { continue } else if err != nil { - return health.Condition{}, err + return Condition{}, err } - k.tell(health.Event{Condition: c, Event: health.EventChanged, At: now, Change: health.ChangeSilenced, - Why: why + " (" + by + ")", Show: health.ShowKeeper(key)}) + k.tell(Event{Condition: c, Event: seat.EventConditionChanged, At: now, Change: cc.ChangeSilenced, + Why: why + " (" + by + ")", Show: cc.ShowKeeper(key)}) return c, nil } - return health.Condition{}, fmt.Errorf("the condition %s kept moving under the silence", key) + return Condition{}, fmt.Errorf("the condition %s kept moving under the silence", key) } diff --git a/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go b/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go index c2d6b3bb2..1b70ecd83 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go @@ -3,14 +3,21 @@ package main import ( "encoding/json" "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "slices" "sort" "strings" "sync" "testing" "time" + cc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + meshhealers "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhealers" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/health" ) // fakeStore is the bus's states in memory, with revisions, as the runtime answers them. @@ -96,12 +103,12 @@ type rig struct { k *Keeper st *fakeStore now time.Time - said []health.Event + said []Event } func newRig() *rig { r := &rig{st: newFakeStore(), now: time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC)} - r.k = &Keeper{Store: r.st, Now: func() time.Time { return r.now }, Say: func(e health.Event) error { + r.k = &Keeper{Store: r.st, Now: func() time.Time { return r.now }, Say: func(e Event) error { r.said = append(r.said, e) return nil }} @@ -117,22 +124,39 @@ func (r *rig) changes() string { return strings.Join(out, ", ") } -func (r *rig) open(key string) (health.Condition, bool) { +func (r *rig) open(key string) (Condition, bool) { raw, _, ok, _ := r.st.Get(ConditionsState, key) - var c health.Condition + var c Condition if ok { - _ = json.Unmarshal(raw, &c) + // The seat's layout, read strictly: a field the seat does not name is a fault of the keeper's write. + v, err := seat.DecodeConditionsByKeyValue(raw) + if err != nil { + panic(fmt.Sprintf("the condition %s is not the seat's layout: %v: %s", key, err, raw)) + } + c = conditionOf(v) } return c, ok } -// finding is an observer's finding as mesh-sdk's layout writes it (health.EncodeFinding). -func finding(t *testing.T, observer, scope, id, kind, source string, sev health.Severity, at time.Time) (string, []byte) { +// encodeFinding writes a finding as an observer does: the seat's layout, checked. +func encodeFinding(f seat.FindingsByKeyValue) ([]byte, error) { + f.V = cc.FindingVersion + if f.Evidence == nil { + f.Evidence = []seat.Evidence{} + } + if err := cc.CheckFinding(f); err != nil { + return nil, err + } + return json.Marshal(f) +} + +// finding is an observer's finding as mesh-sdk's layout writes it (encodeFinding). +func finding(t *testing.T, observer, scope, id, kind, source string, sev string, at time.Time) (string, []byte) { t.Helper() - f := health.Finding{Key: health.Key(scope, id, kind), Kind: kind, Subject: health.Subject{Scope: scope, ID: id}, + f := seat.FindingsByKeyValue{Key: cc.Key(scope, id, kind), Kind: kind, Subject: seat.Subject{Scope: scope, ID: id}, Severity: sev, Summary: kind + " of " + id, Headline: kind, Explanation: "It is " + kind + ".", Source: source, - Observer: observer, At: at, Evidence: []health.Evidence{{At: at, Said: kind}}} - raw, err := health.EncodeFinding(f) + Observer: observer, At: at, Evidence: []seat.Evidence{{At: at, Said: kind}}} + raw, err := encodeFinding(f) if err != nil { t.Fatal(err) } @@ -144,43 +168,43 @@ func finding(t *testing.T, observer, scope, id, kind, source string, sev health. // minutes is the same fault; a silence is a transition in history. func TestTheKeeperRaisesChangesAndClearsFromFindings(t *testing.T) { r := newRig() - key, raw := finding(t, watchman, health.ScopeMachine, "g14", "silent", "S1", health.Warning, r.now) + key, raw := finding(t, watchman, cc.ScopeMachine, "g14", "silent", "S1", cc.Warning, r.now) steps := []struct { name string do func() error said string - check func(c health.Condition, open bool) string + check func(c Condition, open bool) string }{ {name: "a finding written raises", do: func() error { return r.k.Finding(watchman, key, raw, false) }, - said: "raised machine.g14.silent", check: func(c health.Condition, open bool) string { - if !open || c.Severity != health.Warning || c.Resolver != health.ResolverSelf || c.Epoch != 0 || c.Count != 1 { + said: "raised machine.g14.silent", check: func(c Condition, open bool) string { + if !open || c.Severity != cc.Warning || c.Resolver != cc.ResolverSelf || c.Epoch != 0 || c.Count != 1 { return fmt.Sprintf("%+v", c) } return "" }}, {name: "the same finding again says nothing", do: func() error { return r.k.Finding(watchman, key, raw, false) }}, {name: "an observer gone blind clears nothing", do: func() error { r.k.Blind(watchman, "refused"); _, err := r.k.Resync(); return err }, - check: func(c health.Condition, open bool) string { + check: func(c Condition, open bool) string { if !open { return "cleared by blindness" } return "" }}, {name: "a healer's attempt reaches tried and the resolver", do: func() error { - a, _ := json.Marshal(health.Attempt{Healer: "H3", Condition: key, Act: "asserted", Outcome: health.OutcomeActed, Begun: r.now}) + a, _ := json.Marshal(meshhealers.AttemptsByAttemptValue{Healer: "H3", Condition: key, Act: "asserted", Outcome: meshhealers.AttemptsByAttemptValueOutcomeActed, Begun: r.now}) return r.k.Attempt("h3-1", a, false) - }, said: "resolver machine.g14.silent", check: func(c health.Condition, _ bool) string { + }, said: "resolver machine.g14.silent", check: func(c Condition, _ bool) string { if c.Resolver != "healer:H3" || len(c.Tried) != 1 || c.Tried[0].By != "healer H3" { return fmt.Sprintf("%+v", c) } return "" }}, {name: "an escalation makes it urgent, the operator's", do: func() error { - a, _ := json.Marshal(health.Attempt{Healer: "H3", Condition: key, Act: "handed over", Outcome: health.OutcomeEscalated, + a, _ := json.Marshal(meshhealers.AttemptsByAttemptValue{Healer: "H3", Condition: key, Act: "handed over", Outcome: meshhealers.AttemptsByAttemptValueOutcomeEscalated, Begun: r.now.Add(time.Minute)}) return r.k.Attempt("h3-2", a, false) - }, said: "severity machine.g14.silent, resolver machine.g14.silent", check: func(c health.Condition, _ bool) string { - if c.Severity != health.Urgent || c.Resolver != health.ResolverOperator || len(c.Tried) != 2 { + }, said: "severity machine.g14.silent, resolver machine.g14.silent", check: func(c Condition, _ bool) string { + if c.Severity != cc.Urgent || c.Resolver != cc.ResolverOperator || len(c.Tried) != 2 { return fmt.Sprintf("%+v", c) } return "" @@ -190,7 +214,7 @@ func TestTheKeeperRaisesChangesAndClearsFromFindings(t *testing.T) { return err }, said: "silenced machine.g14.silent"}, {name: "the finding deleted clears", do: func() error { return r.k.Finding(watchman, key, nil, true) }, - said: "cleared machine.g14.silent", check: func(_ health.Condition, open bool) string { + said: "cleared machine.g14.silent", check: func(_ Condition, open bool) string { if open { return "still open" } @@ -199,7 +223,7 @@ func TestTheKeeperRaisesChangesAndClearsFromFindings(t *testing.T) { {name: "raised again within ten minutes is a reopening", do: func() error { r.now = r.now.Add(5 * time.Minute) return r.k.Finding(watchman, key, raw, false) - }, said: "reopened machine.g14.silent", check: func(c health.Condition, _ bool) string { + }, said: "reopened machine.g14.silent", check: func(c Condition, _ bool) string { if c.Count != 2 || len(c.Gaps) != 1 { return fmt.Sprintf("%+v", c) } @@ -236,17 +260,17 @@ func TestTheKeeperRaisesChangesAndClearsFromFindings(t *testing.T) { // once by EndCarried, said as cleared; a condition an observer's finding holds stays; a second EndCarried clears nothing. func TestTheKeeperCarriesNoControllerConditionAndClearsWhatItCarriedOnce(t *testing.T) { r := newRig() - key, raw := finding(t, watchman, health.ScopeMachine, "g14", "silent", "S1", health.Warning, r.now) + key, raw := finding(t, watchman, cc.ScopeMachine, "g14", "silent", "S1", cc.Warning, r.now) if err := r.k.Finding(watchman, key, raw, false); err != nil { t.Fatal(err) } r.said = nil // Carried while the controller ran: two of kinds that ended with it, still open in conditions. - for _, c := range []health.Condition{ - {Key: "core.controller.deaf", Kind: "controller-deaf", Severity: health.Urgent, Resolver: "self", Count: 1}, - {Key: "seat.vault.ace.holder-silent", Kind: "holder-silent", Severity: health.Warning, Resolver: "self", Count: 1}, + for _, c := range []Condition{ + {Key: "core.controller.deaf", Kind: "controller-deaf", Severity: cc.Urgent, Resolver: "self", Count: 1}, + {Key: "seat.vault.ace.holder-silent", Kind: "holder-silent", Severity: cc.Warning, Resolver: "self", Count: 1}, } { - if _, err := r.st.Create(ConditionsState, c.Key, c); err != nil { + if _, err := r.st.Create(ConditionsState, c.Key, c.Stored()); err != nil { t.Fatal(err) } } @@ -276,10 +300,17 @@ func TestEveryControllerKindHasItsHome(t *testing.T) { } for kind, observer := range map[string]string{"needs-operator": watchman, "machine-network": watchman, "lease-split": selfCheck, "data-shrank": selfCheck, "retire-waiting": provisioner, "asks-undelivered": messenger, - "image-missing": artifactStore} { + "image-missing": artifactStore, "build-failed": builder, "floor-silent": builder, "old-form-held": builder, + "tag-removal-not-done": builder, "seat-definition-unparsed": indexer, "generated-types-out-of-step": indexer, + "seat-defined-twice": indexer, "accepted-unsaid": indexer, "manifest-held": indexer, "packages-token-refused": gitea, + "publish-refused": builder, "publish-unanswered": builder, "seat-outcome-unread": builder} { if h, ok := Homes[kind]; !ok || h.Observer != observer || h.Carried { t.Errorf("%s's home is %+v", kind, h) } + // The keeper reads every observer's findings that a kind's home names (the registry milestone's join). + if !slices.Contains(Observers, observer) { + t.Errorf("%s's observer %s is not read by the keeper", kind, observer) + } } for _, kind := range []string{"resolver-wrong", "holder-silent", "archives-missing", "archives-unheld", "binding-kept", "binding-moved", "binding-moving", "grant-composes-nothing", "root-not-free", "cleanup-waiting", "data-held-twice"} { @@ -294,7 +325,95 @@ func TestEveryControllerKindHasItsHome(t *testing.T) { t.Errorf("the ended probe's kind %s is %+v", kind, h) } } - if len(Homes) != 84 || carried != 37 { // 19 of ADR 0564, the 63 of the table, plan-failed and stalled + // The readers' unanswered first view and the owners' views too large to say are every reader's and owner's own. + for _, kind := range []string{"first-view-unanswered", "view-too-large", "view-unsaid", "family-not-heard"} { + if h := Homes[kind]; h.Observer != "" || h.Carried || h.Controller { + t.Errorf("%s's home is %+v", kind, h) + } + } + // Each module that writes a reader's unanswered first view (ADR 0615 §6), or that a delivery part's consumer stopped + // hearing, is read: the delivery parts and the tool runner by name (other repositories), and every catalogue module + // whose code writes a views finding, found in the catalogue's tree, so a new writer cannot go unobserved. + readers := []string{watchman, "mesh-delivery-teller", enrolment, builder, "mesh-delivery-checker", "mesh-delivery-view", + indexer, assignerModule, configModule, vaultModule, gitea, "node-tools"} + readers = append(readers, viewFindingWriters(t, "../../..")...) + for _, reader := range readers { + if !slices.Contains(Observers, reader) { + t.Errorf("the reader %s's findings are not read by the keeper", reader) + } + } + // 19 of ADR 0564, the 63 of the table, plan-failed and stalled, mesh-config's four (design 32), the readers' unanswered + // first view, the owners' view too large, the builder's four, the indexer's four and the package seats' token refused. + // And the builder's three publishing kinds and the delivery parts' family not heard: 103. + // And the owners' changes not said, view-unsaid: 104. And the indexer's mesh-wide hold past its bound, manifest-held + // (ADR 0632 §2): 105. (The bus module's own conditions, nats.status-unwritten among them, are said to the keeper + // directly, never through findings, so they have no home here.) + if len(Homes) != 105 || carried != 37 { t.Errorf("the table holds %d kinds, %d carried", len(Homes), carried) } } + +// viewFindingWriters are the catalogue modules (under modules) whose non-test Go code writes a views finding into its own +// findings: views.UnansweredFinding, views.TooLargeFinding, views.UnsaidFinding or views.OwnFindings. +func viewFindingWriters(t *testing.T, modules string) []string { + t.Helper() + writes := regexp.MustCompile(`(views\.(UnansweredFinding|TooLargeFinding|UnsaidFinding)|meshconditionsclient\.FindingWriter)\b`) + var out []string + entries, err := os.ReadDir(modules) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if !e.IsDir() || e.Name() == Module { + continue + } + found := false + _ = filepath.WalkDir(filepath.Join(modules, e.Name()), func(path string, d fs.DirEntry, err error) error { + if err != nil || found || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + if src, err := os.ReadFile(path); err == nil && writes.Match(src) { + found = true + } + return nil + }) + if found { + out = append(out, e.Name()) + } + } + if len(out) < 5 { + t.Fatalf("found %d catalogue modules writing views findings in %s: the tree was not read (%v)", len(out), modules, out) + } + return out +} + +// ADR 0634 §1, §4 (issue 1966): the keeper reads a condition written before the seat named its layout (the controller's +// body: `silenced` null, `actions` empty, the times with an offset) and writes it back in the seat's layout. +func TestTheKeeperReadsTheOldLayoutAndWritesTheSeatsLayout(t *testing.T) { + r := newRig() + key, raw := finding(t, watchman, cc.ScopeMachine, "g14", "silent", "S1", cc.Warning, r.now) + old := `{"key":"` + key + `","kind":"silent","subject":{"scope":"machine","id":"g14"},"severity":"warning",` + + `"summary":"old","headline":"","explanation":"","resolved":"","needs":"","actions":[],"evidence":[],"source":"S1",` + + `"raised":"2026-10-29T13:00:00+01:00","last-observed":"2026-10-29T13:00:00+01:00","observations":1,"count":1,` + + `"resolver":"self","silenced":null,"epoch":0}` + if _, err := r.st.Create(ConditionsState, key, json.RawMessage(old)); err != nil { + t.Fatal(err) + } + if err := r.k.Finding(watchman, key, raw, false); err != nil { + t.Fatal(err) + } + c, ok := r.open(key) + if !ok || c.Summary != "silent of g14" || !c.Raised.Equal(r.now) || c.Count != 1 { + t.Fatalf("the old condition, settled: %+v (open %v)", c, ok) + } + if _, err := r.k.Silence(key, time.Hour, "tester", "known"); err != nil { + t.Fatal(err) + } + if c, _ := r.open(key); c.Silenced == nil || !c.Silenced.Until.Equal(r.now.Add(time.Hour)) { + t.Errorf("the silence is not kept in the seat's layout: %+v", c.Silenced) + } + last := r.said[len(r.said)-1].Said() + if last.Change != cc.ChangeSilenced || last.Silenced == nil || last.Subject == nil || last.Subject.ID != "g14" || last.Show == "" { + t.Errorf("the event the seat hears: %+v", last) + } +} diff --git a/modules/mesh-conditions/cmd/mesh-conditions/main.go b/modules/mesh-conditions/cmd/mesh-conditions/main.go index a70504ecd..d57315678 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/main.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/main.go @@ -8,12 +8,18 @@ package main import ( + "context" + "encoding/json" "fmt" "os" "time" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/health" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" + seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + "git.novox.be/novox/mesh-sdk/go/views" ) func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) } @@ -48,8 +54,8 @@ func (runtimeStore) CreateTTL(state, key string, value any, ttl time.Duration) e } // sayOnSeat says a transition on the seat, as the controller says it on its own (ADR 0564 clause 3). -func sayOnSeat(e health.Event) error { - return stdio.SeatPublish("mesh.seat."+Seat+".event."+e.Event, e, "") +func sayOnSeat(e Event) error { + return stdio.SeatPublish("mesh.seat."+Seat+".event."+e.Event, e.Said(), "") } // NewKeeper is the keeper over the runtime. @@ -59,10 +65,22 @@ func NewKeeper() *Keeper { // watch keeps a watch of one state, said blind while it cannot be read: a state not on the bus yet (an observer not // delivered) is read again when it is. -func watch(k *Keeper, state, observer string, handle func(stdio.StateChange) error) { +func watch(k *Keeper, v *views.Reader, state, observer string, handle func(stdio.StateChange) error) { go func() { for wait := 5 * time.Second; ; wait = min(wait*2, 5*time.Minute) { - err := stdio.State(state).Watch("", handle) + var err error + if v.Follows(state) { + // The assigner's and the indexer's states are asked once of their read tools and followed by their events (ADR 0615). + err = v.Watch(state, func(key string, value []byte, gone bool) { + op := stdio.OpPut + if gone { + op = stdio.OpDelete + } + _ = handle(stdio.StateChange{Key: key, Value: value, Op: op}) + }) + } else { + err = stdio.State(state).Watch("", handle) + } if err == nil { if observer != "" { k.Blind(observer, "") @@ -79,7 +97,7 @@ func watch(k *Keeper, state, observer string, handle func(stdio.StateChange) err } // Keep starts every watch the keeper reads by (clauses 2, 8, 9, 13). -func Keep(k *Keeper) { +func Keep(k *Keeper, v *views.Reader) { for _, s := range []string{HeldState} { keys, _ := stdio.State(s).Keys() for _, key := range keys { @@ -99,35 +117,75 @@ func Keep(k *Keeper) { if n, err := k.EndCarried(); n > 0 || err != nil { logf("cleared %d condition(s) of kinds that ended with the controller: %v", n, err) } - watch(k, WantsRead, "", func(c stdio.StateChange) error { return said(k.Want(c.Key, c.Value, c.Op != "put")) }) - watch(k, HavesRead, "", func(c stdio.StateChange) error { return said(k.Have(c.Key, c.Value, c.Op != "put")) }) - watch(k, AttemptsRead, health.HealersModule+" attempts", func(c stdio.StateChange) error { + watch(k, v, WantsRead, "", func(c stdio.StateChange) error { return said(k.Want(c.Key, c.Value, c.Op != "put")) }) + watch(k, v, HavesRead, "", func(c stdio.StateChange) error { return said(k.Have(c.Key, c.Value, c.Op != "put")) }) + watch(k, v, AttemptsRead, healersModule+" attempts", func(c stdio.StateChange) error { return said(k.Attempt(c.Key, c.Value, c.Op != "put")) }) for _, o := range Observers { - watch(k, o+"."+health.FindingsState, o, func(c stdio.StateChange) error { + watch(k, v, o+"."+seat.StateFindings, o, func(c stdio.StateChange) error { return said(k.Finding(o, c.Key, c.Value, c.Op != "put")) }) } - watch(k, BoundsState, "", func(c stdio.StateChange) error { + watch(k, v, BoundsState, "", func(c stdio.StateChange) error { if c.Op == stdio.OpExpire { return said(k.BoundPassed(c.Key)) } return nil }) - watch(k, PlacementRead, "", func(c stdio.StateChange) error { - k.Placement(c.Key, c.Value, c.Op != stdio.OpPut) + watch(k, v, AssignmentsRead, "", func(c stdio.StateChange) error { + k.Assignments(c.Key, c.Value, c.Op != stdio.OpPut) return nil }) } +// The assigner's read tools of the states the keeper follows, which the manifest invokes (ADR 0615). +const ( + WantsTool = assignerModule + ".assigner_read_wants" + AssignmentsTool = assignerModule + ".assigner_read_assignments" + FindingsTool = assignerModule + ".assigner_read_findings" + // IndexerFindingsTool is the indexer's read tool of its findings: the indexer's state, too, is asked of its owner. + IndexerFindingsTool = indexerModule + ".indexer_read_findings" +) + +// NewViews is the keeper's pictures of the assigner's wants, assignments and findings and of the indexer's findings. A first view it cannot get past its +// bound is a finding of its own, taken in as any observer's: the keeper raises the condition itself. +func NewViews(k *Keeper) *views.Reader { + v := &views.Reader{Module: Module, Logf: logf, Findings: ownFindings{k}, Acts: meshhandactsclient.Recorder{Ask: stdio.Ask}} + v.Follow(assignerModule, "wants", WantsTool) + v.Follow(assignerModule, "assignments", AssignmentsTool) + v.Follow(assignerModule, seat.StateFindings, FindingsTool) + v.Follow(indexerModule, seat.StateFindings, IndexerFindingsTool) + return v +} + +// ownFindings takes the keeper's own finding straight into its picture of findings. +type ownFindings struct{ k *Keeper } + +func (o ownFindings) Put(f views.Finding) error { + v := meshconditionsclient.FindingFor(f) + if err := meshconditionsclient.CheckFinding(v); err != nil { + return err + } + value, err := json.Marshal(v) + if err != nil { + return err + } + return o.k.Finding(Module, v.Key, value, false) +} +func (o ownFindings) Delete(f views.Finding) error { + return o.k.Finding(Module, meshconditionsclient.Key(meshconditionsclient.ScopeModule, f.ID, f.Kind), nil, true) +} + func main() { k := NewKeeper() + v := NewViews(k) go func() { <-stdio.Served() - Keep(k) + v.Start(context.Background()) + Keep(k, v) }() - if err := stdio.Serve("", Tools(k)); err != nil { + if err := stdio.Serve("", append(Tools(k), v.Tools("mesh_conditions")...)); err != nil { logf("%v", err) os.Exit(1) } diff --git a/modules/mesh-conditions/cmd/mesh-conditions/manifest_test.go b/modules/mesh-conditions/cmd/mesh-conditions/manifest_test.go index 79ccbe5c6..3750c0d33 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/manifest_test.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/manifest_test.go @@ -6,8 +6,10 @@ import ( "slices" "testing" + seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + meshhealers "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhealers" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/health" + "git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest" ) // ADR 0564 clauses 1, 2, 3 and 12 (issue 1952 C1): the manifest says what the code does. It claims the seat @@ -24,19 +26,29 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { if err != nil { t.Fatalf("the manifest does not decode: %v", err) } - seat, _ := catalogue.SeatNamed(Seat) + catalogue.UseSeats(catalogue.SeatsFromDefinitions(seatlisttest.Definitions())) + def, _ := catalogue.SeatNamed(Seat) if m.Module != Module || m.RunsAs != Module || len(m.Claims) != 1 || m.Claims[0].Name != Seat || - !slices.Equal(Verbs, catalogue.VerbNames(seat.Serves)) { - t.Errorf("module %s runs as %s and claims %+v; the seat serves %v", m.Module, m.RunsAs, m.Claims, catalogue.VerbNames(seat.Serves)) + !slices.Equal(Verbs, catalogue.VerbNames(def.Serves)) { + t.Errorf("module %s runs as %s and claims %+v; the def serves %v", m.Module, m.RunsAs, m.Claims, catalogue.VerbNames(def.Serves)) } - reads := []string{WantsRead, HavesRead, AttemptsRead, PlacementRead} + // The assigner's wants, assignments and findings, and the indexer's findings, are asked once of their read tools and + // followed by their events, not read (ADR 0615 §1). + reads := []string{HavesRead, AttemptsRead} for _, o := range Observers { - reads = append(reads, o+"."+health.FindingsState) + if o != assignerModule && o != indexerModule { + reads = append(reads, o+"."+seat.StateFindings) + } + } + if !slices.Equal(m.Invokes, []string{WantsTool, AssignmentsTool, FindingsTool, IndexerFindingsTool, "seat:mesh-hand-acts.hand-act"}) || + !slices.Equal(m.Consumes, []string{"mesh-delivery-assigner.views.wants", "mesh-delivery-assigner.views.assignments", + "mesh-delivery-assigner.views.findings", "mesh-delivery-indexer.views.findings"}) { + t.Errorf("invokes %v, consumes %v", m.Invokes, m.Consumes) } slices.Sort(reads) got := slices.Clone(m.Reads) slices.Sort(got) - if slices.Contains(m.Reads, "mesh-controller.conditions") || slices.Contains(m.Reads, "mesh-controller.lease") || !slices.Equal(got, reads) || slices.Contains(m.Reads, health.HealersModule+"."+health.WouldState) { + if slices.Contains(m.Reads, "mesh-controller.conditions") || slices.Contains(m.Reads, "mesh-controller.lease") || !slices.Equal(got, reads) || slices.Contains(m.Reads, healersModule+"."+meshhealers.StateWould) { t.Errorf("reads %v, want %v and never the healers' would", got, reads) } ttl, keyTTL := map[string]int{}, map[string]bool{} diff --git a/modules/mesh-conditions/cmd/mesh-conditions/model.go b/modules/mesh-conditions/cmd/mesh-conditions/model.go new file mode 100644 index 000000000..36d4d0247 --- /dev/null +++ b/modules/mesh-conditions/cmd/mesh-conditions/model.go @@ -0,0 +1,241 @@ +package main + +// The keeper's working model of a condition and its events, over the seat mesh-conditions' contract types (novox/hq +// ADR 0564 clause 3, ADR 0634 §1, §4). The seat's types keep an optional time as a pointer; the keeper's rules read +// better over plain times, so the keeper works on its own Condition and Event and converts at the edge: what it writes +// to `conditions` and `history`, and what it says on the seat, is the seat's form, and what it reads is converted back. + +import ( + "encoding/json" + "fmt" + "sort" + "time" + + seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + meshhealers "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhealers" + + cc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" +) + +// Silence is a person saying they know: no messages until it ends. The condition stays open. +type Silence struct { + Until time.Time + By string + Why string + Since time.Time +} + +// Gap is a stretch in which a fault was cleared, between its clearing and its reopening. +type Gap struct{ Cleared, Reopened time.Time } + +// Tried is one healer's try at a condition, as the condition carries it in `tried`. +type Tried struct { + At time.Time + What string + Outcome string + By string +} + +// Condition is one open condition as the keeper works on it. +type Condition struct { + Key string + Kind string + Subject seat.Subject + Severity string + Summary string + Headline string + Explanation string + Resolved string + Needs string + Actions []seat.Action + Evidence []seat.Evidence + Source string + Raised time.Time + LastObserved time.Time + // First and Gaps are the fault's earlier life: a reopening within ReopenWithin is the same fault. + First time.Time + Gaps []Gap + Observations int + Count int + Tried []Tried + Resolver string + // Silenced is nil when no silence is in force. + Silenced *Silence + Epoch int64 + LooksUnread string +} + +// Escalated says a healer tried and its budget is spent: the operator resolves it now. +func (c Condition) Escalated() bool { return c.Resolver == cc.ResolverOperator && len(c.Tried) > 0 } + +// Began is when this fault began as the mesh knows it: its first raising, a reopening within ReopenWithin being the +// same fault again. +func (c Condition) Began() time.Time { + if !c.First.IsZero() && c.First.Before(c.Raised) { + return c.First + } + return c.Raised +} + +// Event is one transition: the condition itself with what happened to it beside. +type Event struct { + Condition + Event string + At time.Time + Change string + Was string + Why string + Cleared *time.Time + Show string +} + +func timeP(t time.Time) *time.Time { + if t.IsZero() { + return nil + } + return &t +} + +func timeOf(t *time.Time) time.Time { + if t == nil { + return time.Time{} + } + return *t +} + +// Stored is the condition as the seat's state `conditions` keeps it. +func (c Condition) Stored() seat.ConditionsByKeyValue { + v := seat.ConditionsByKeyValue{Key: c.Key, Kind: c.Kind, Subject: c.Subject, Severity: c.Severity, Summary: c.Summary, + Headline: c.Headline, Explanation: c.Explanation, Resolved: c.Resolved, Needs: c.Needs, Actions: c.Actions, + Evidence: c.Evidence, Source: c.Source, Raised: c.Raised, LastObserved: c.LastObserved, First: timeP(c.First), + Observations: int64(c.Observations), Count: int64(c.Count), Resolver: c.Resolver, Epoch: c.Epoch, + LooksUnread: c.LooksUnread} + for _, g := range c.Gaps { + v.Gaps = append(v.Gaps, seat.ConditionsByKeyValueGapsItem{Cleared: timeP(g.Cleared), Reopened: timeP(g.Reopened)}) + } + for _, t := range c.Tried { + v.Tried = append(v.Tried, seat.ConditionsByKeyValueTriedItem{At: timeP(t.At), What: t.What, Outcome: t.Outcome, By: t.By}) + } + if c.Silenced != nil { + v.Silenced = &seat.ConditionsByKeyValueSilenced{Until: timeP(c.Silenced.Until), By: c.Silenced.By, + Why: c.Silenced.Why, Since: timeP(c.Silenced.Since)} + } + return v +} + +// conditionOf is the working condition of a stored one. +func conditionOf(v seat.ConditionsByKeyValue) Condition { + c := Condition{Key: v.Key, Kind: v.Kind, Subject: v.Subject, Severity: v.Severity, Summary: v.Summary, + Headline: v.Headline, Explanation: v.Explanation, Resolved: v.Resolved, Needs: v.Needs, Actions: v.Actions, + Evidence: v.Evidence, Source: v.Source, Raised: v.Raised, LastObserved: v.LastObserved, First: timeOf(v.First), + Observations: int(v.Observations), Count: int(v.Count), Resolver: v.Resolver, Epoch: v.Epoch, + LooksUnread: v.LooksUnread} + for _, g := range v.Gaps { + c.Gaps = append(c.Gaps, Gap{Cleared: timeOf(g.Cleared), Reopened: timeOf(g.Reopened)}) + } + for _, t := range v.Tried { + c.Tried = append(c.Tried, Tried{At: timeOf(t.At), What: t.What, Outcome: t.Outcome, By: t.By}) + } + if v.Silenced != nil { + c.Silenced = &Silence{Until: timeOf(v.Silenced.Until), By: v.Silenced.By, Why: v.Silenced.Why, + Since: timeOf(v.Silenced.Since)} + } + return c +} + +// decodeCondition reads a condition of the state `conditions`; the seat's layout is read as it is written, one field +// the keeper does not know being the seat's change, not a fault. +func decodeCondition(raw []byte) (Condition, error) { + var v seat.ConditionsByKeyValue + if err := json.Unmarshal(raw, &v); err != nil { + return Condition{}, err + } + return conditionOf(v), nil +} + +// Said is the event as the seat says it and `history` keeps it. +func (e Event) Said() seat.ConditionEvent { + s := e.Condition.Stored() + v := seat.ConditionEvent{Key: s.Key, Kind: s.Kind, Subject: &s.Subject, Severity: s.Severity, Summary: s.Summary, + Headline: s.Headline, Explanation: s.Explanation, Resolved: s.Resolved, Needs: s.Needs, Actions: s.Actions, + Evidence: s.Evidence, Source: s.Source, Raised: timeP(s.Raised), LastObserved: timeP(s.LastObserved), First: s.First, + Observations: s.Observations, Count: s.Count, Resolver: s.Resolver, Epoch: s.Epoch, LooksUnread: s.LooksUnread, + Event: e.Event, At: e.At, Change: e.Change, Was: e.Was, Why: e.Why, Cleared: e.Cleared, Show: e.Show} + for _, g := range s.Gaps { + v.Gaps = append(v.Gaps, seat.ConditionEventGapsItem{Cleared: g.Cleared, Reopened: g.Reopened}) + } + for _, t := range s.Tried { + v.Tried = append(v.Tried, seat.ConditionEventTriedItem{At: t.At, What: t.What, Outcome: t.Outcome, By: t.By}) + } + if s.Silenced != nil { + v.Silenced = &seat.ConditionEventSilenced{Until: s.Silenced.Until, By: s.Silenced.By, Why: s.Silenced.Why, + Since: s.Silenced.Since} + } + return v +} + +// eventOf is the working event of one the seat says. +func eventOf(v seat.ConditionEvent) Event { + s := seat.ConditionsByKeyValue{Key: v.Key, Kind: v.Kind, Severity: v.Severity, Summary: v.Summary, + Headline: v.Headline, Explanation: v.Explanation, Resolved: v.Resolved, Needs: v.Needs, Actions: v.Actions, + Evidence: v.Evidence, Source: v.Source, Raised: timeOf(v.Raised), LastObserved: timeOf(v.LastObserved), + First: v.First, Observations: v.Observations, Count: v.Count, Resolver: v.Resolver, Epoch: v.Epoch, + LooksUnread: v.LooksUnread} + if v.Subject != nil { + s.Subject = *v.Subject + } + for _, g := range v.Gaps { + s.Gaps = append(s.Gaps, seat.ConditionsByKeyValueGapsItem{Cleared: g.Cleared, Reopened: g.Reopened}) + } + for _, t := range v.Tried { + s.Tried = append(s.Tried, seat.ConditionsByKeyValueTriedItem{At: t.At, What: t.What, Outcome: t.Outcome, By: t.By}) + } + if v.Silenced != nil { + s.Silenced = &seat.ConditionsByKeyValueSilenced{Until: v.Silenced.Until, By: v.Silenced.By, Why: v.Silenced.Why, + Since: v.Silenced.Since} + } + return Event{Condition: conditionOf(s), Event: v.Event, At: v.At, Change: v.Change, Was: v.Was, Why: v.Why, + Cleared: v.Cleared, Show: v.Show} +} + +// order sorts conditions as `conditions` says them. +func order(list []Condition) { + sort.SliceStable(list, func(i, j int) bool { + if list[i].Severity != list[j].Severity { + return list[i].Severity == cc.Urgent + } + if !list[i].Raised.Equal(list[j].Raised) { + return list[i].Raised.Before(list[j].Raised) + } + return list[i].Key < list[j].Key + }) +} + +// decodeFinding reads an observer's finding of the seat's state `findings`, refusing one the keeper could not make a +// condition of. A finding written before `ref` was named has it as `token`. +func decodeFinding(data []byte) (seat.FindingsByKeyValue, error) { + var f seat.FindingsByKeyValue + if err := json.Unmarshal(data, &f); err != nil { + return f, fmt.Errorf("a finding is not readable: %w", err) + } + if f.Ref == "" { + var old struct { + Token string `json:"token"` + } + _ = json.Unmarshal(data, &old) + f.Ref = old.Token + } + if f.V != cc.FindingVersion { + return f, fmt.Errorf("a finding of version %d, and this reads %d", f.V, cc.FindingVersion) + } + return f, cc.CheckFinding(f) +} + +// triedOf is the attempt as the condition carries it in `tried`. +func triedOf(a meshhealers.AttemptsByAttemptValue) Tried { + outcome := a.Outcome + if a.Said != "" { + outcome += ": " + a.Said + } + return Tried{At: a.Begun, What: a.Act, Outcome: outcome, By: "healer " + a.Healer} +} diff --git a/modules/mesh-conditions/cmd/mesh-conditions/tools.go b/modules/mesh-conditions/cmd/mesh-conditions/tools.go index a1324aadb..9e0d80044 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/tools.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/tools.go @@ -13,7 +13,9 @@ import ( "time" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/health" + + cc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" ) // Verbs are the seat's verbs, as the manifest claims them. @@ -31,7 +33,7 @@ const briefEvidence = 1 // inBrief is a condition in a list: its newest evidence, with how many it keeps and how to read it whole. type inBrief struct { - health.Condition + seat.ConditionsByKeyValue EvidenceKept int `json:"evidence-kept,omitempty"` More string `json:"more,omitempty"` } @@ -67,7 +69,8 @@ func Tools(k *Keeper) []stdio.Tool { if err != nil { return nil, err } - return k.Silence(strArg(args, "key"), d, stdio.CallerOf(args), strArg(args, "why")) + c, err := k.Silence(strArg(args, "key"), d, stdio.CallerOf(args), strArg(args, "why")) + return c.Stored(), err }}, {Name: Seat + ".resync", Description: "Read every finding again and raise and clear what differs.", Run: func(map[string]any) (any, error) { @@ -109,11 +112,11 @@ func (k *Keeper) ConditionsVerb(args map[string]any) (any, error) { if !found || string(raw) == "null" { return nil, fmt.Errorf("no condition %s is open", key) } - var c health.Condition - return c, json.Unmarshal(raw, &c) + c, err := decodeCondition(raw) + return c.Stored(), err } scope, severity, machine := strArg(args, "scope"), strArg(args, "severity"), strArg(args, "machine") - if severity != "" && severity != string(health.Urgent) && severity != string(health.Warning) { + if severity != "" && severity != cc.Urgent && severity != cc.Warning { return nil, fmt.Errorf("a severity is urgent or warning, not %q", severity) } open, err := k.Open() @@ -123,13 +126,13 @@ func (k *Keeper) ConditionsVerb(args map[string]any) (any, error) { list := []inBrief{} counted := map[string]int{} for _, c := range open { - if (scope != "" && c.Subject.Scope != scope) || (severity != "" && string(c.Severity) != severity) || + if (scope != "" && c.Subject.Scope != scope) || (severity != "" && c.Severity != severity) || (machine != "" && !concerns(c, machine)) { continue } - b := inBrief{Condition: c} + b := inBrief{ConditionsByKeyValue: c.Stored()} if len(c.Evidence) > briefEvidence { - b.EvidenceKept, b.Evidence, b.More = len(c.Evidence), c.Evidence[:briefEvidence:briefEvidence], health.ShowKeeper(c.Key) + b.EvidenceKept, b.Evidence, b.More = len(c.Evidence), c.Evidence[:briefEvidence:briefEvidence], cc.ShowKeeper(c.Key) } list = append(list, b) counted[c.Source+" "+c.Kind]++ @@ -140,18 +143,18 @@ func (k *Keeper) ConditionsVerb(args map[string]any) (any, error) { } // concerns says whether a condition is about a machine: it names it, or its key does. -func concerns(c health.Condition, machine string) bool { +func concerns(c Condition, machine string) bool { return c.Subject.Machine == machine || slices.Contains(c.Subject.Also, machine) || slices.Contains(strings.Split(c.Key, "."), machine) } // History is the newest transitions, at most limit, of one key or all. -func (k *Keeper) History(key string, limit int) ([]health.Event, error) { +func (k *Keeper) History(key string, limit int) ([]seat.ConditionEvent, error) { all, err := k.HistorySince(key, time.Time{}, 0) if err != nil { return nil, err } - events := all["history"].([]health.Event) + events := all["history"].([]seat.ConditionEvent) slices.Reverse(events) if len(events) > limit { events = events[:limit] @@ -165,13 +168,13 @@ func (k *Keeper) HistorySince(key string, since time.Time, days int) (map[string if err != nil { return nil, err } - events := []health.Event{} + events := []seat.ConditionEvent{} for _, hk := range keys { raw, _, found, err := k.Store.Get(HistoryState, hk) if err != nil || !found { continue } - var e health.Event + var e seat.ConditionEvent if json.Unmarshal(raw, &e) != nil || e.At.Before(since) || (key != "" && e.Key != key) { continue } diff --git a/modules/mesh-conditions/cmd/mesh-conditions/whole_integration_test.go b/modules/mesh-conditions/cmd/mesh-conditions/whole_integration_test.go index 637685bde..7abe372d3 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/whole_integration_test.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/whole_integration_test.go @@ -27,7 +27,8 @@ import ( "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" - "git.novox.be/novox/mesh-sdk/go/health" + cc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" "git.novox.be/novox/mesh-sdk/go/testbus" ) @@ -261,7 +262,7 @@ func TestTheKeeperWholeRaisesAndClearsOnTheBus(t *testing.T) { if err != nil { t.Fatal(err) } - buckets := []string{"mesh-controller_conditions", "mesh-delivery-assigner_wants", "node-engine_have", "mesh-delivery-assigner_placement", + buckets := []string{"mesh-controller_conditions", "mesh-delivery-assigner_wants", "node-engine_have", "mesh-delivery-assigner_assignments", "mesh-healers_attempts"} for _, o := range Observers { buckets = append(buckets, o+"_findings") @@ -296,15 +297,15 @@ func TestTheKeeperWholeRaisesAndClearsOnTheBus(t *testing.T) { } time.Sleep(time.Second) at := time.Now().UTC() - f := health.Finding{Key: "machine.g14.silent", Kind: "silent", Subject: health.Subject{Scope: health.ScopeMachine, ID: "g14", - Machine: "g14"}, Severity: health.Warning, Summary: "g14 is silent", Headline: "g14 is offline", + f := seat.FindingsByKeyValue{Key: "machine.g14.silent", Kind: "silent", Subject: seat.Subject{Scope: cc.ScopeMachine, ID: "g14", + Machine: "g14"}, Severity: cc.Warning, Summary: "g14 is silent", Headline: "g14 is offline", Explanation: "g14 said nothing for three minutes.", Resolved: "g14 is back", Source: "S1", - Observer: health.WatchmanModule, At: at, Evidence: []health.Evidence{{At: at, Said: "no presence for 3m"}}} - raw, err := health.EncodeFinding(f) + Observer: watchmanModule, At: at, Evidence: []seat.Evidence{{At: at, Said: "no presence for 3m"}}} + raw, err := encodeFinding(f) if err != nil { t.Fatal(err) } - findings, _ := js.KeyValue(ctx, health.WatchmanModule+"_findings") + findings, _ := js.KeyValue(ctx, watchmanModule+"_findings") if _, err := findings.Put(ctx, f.Key, raw); err != nil { t.Fatal(err) } @@ -312,9 +313,9 @@ func TestTheKeeperWholeRaisesAndClearsOnTheBus(t *testing.T) { if err != nil { t.Fatalf("no condition-raised was said: %v", err) } - var e health.Event + var e Event if err := json.Unmarshal(msg.Data, &e); err != nil || msg.Subject != "mesh.seat.mesh-conditions.event.condition-raised" || - e.Key != f.Key || e.Change != health.ChangeRaised || e.Show != health.ShowKeeper(f.Key) || e.Headline != f.Headline { + e.Key != f.Key || e.Change != cc.ChangeRaised || e.Show != cc.ShowKeeper(f.Key) || e.Headline != f.Headline { t.Fatalf("said %s on %s", msg.Data, msg.Subject) } listed, err := p.call(Seat+".conditions", map[string]any{}) diff --git a/modules/mesh-conditions/go.mod b/modules/mesh-conditions/go.mod index db2a3a445..4666a41cc 100644 --- a/modules/mesh-conditions/go.mod +++ b/modules/mesh-conditions/go.mod @@ -3,20 +3,31 @@ module mesh-conditions go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhealers v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 github.com/nats-io/nats.go v1.54.0 ) require ( - github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect + github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/minio/highwayhash v1.0.4 // indirect - github.com/nats-io/jwt/v2 v2.8.1 // indirect + github.com/nats-io/jwt/v2 v2.8.2 // indirect github.com/nats-io/nats-server/v2 v2.11.17 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect golang.org/x/crypto v0.57.0 // indirect golang.org/x/sys v0.48.0 // indirect - golang.org/x/time v0.15.0 // indirect + golang.org/x/time v0.16.0 // indirect ) + +require git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000 // indirect diff --git a/modules/mesh-conditions/go.sum b/modules/mesh-conditions/go.sum index 43cc4ec77..8ca93dfd0 100644 --- a/modules/mesh-conditions/go.sum +++ b/modules/mesh-conditions/go.sum @@ -1,15 +1,13 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= -github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= -github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc= +github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc= github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= @@ -23,5 +21,5 @@ golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZ golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= diff --git a/modules/mesh-conditions/module.json b/modules/mesh-conditions/module.json index ce6ecbae8..af7ed4ac5 100644 --- a/modules/mesh-conditions/module.json +++ b/modules/mesh-conditions/module.json @@ -17,14 +17,38 @@ "mesh-delivery-watchman.findings", "mesh-self-check.findings", "mesh-healers.findings", - "mesh-delivery-assigner.findings", "messenger.findings", "mesh-provisioner.findings", "artifact-store-tools.findings", + "mesh-config.findings", + "mesh-vault.findings", + "mesh-watcher.findings", + "minio.findings", + "nfs-server.findings", + "gitea.findings", + "mesh-build-go.findings", + "mesh-delivery-builder.findings", + "mesh-delivery-checker.findings", + "mesh-delivery-teller.findings", + "mesh-delivery-view.findings", + "mesh-enrolment.findings", + "node-tools.findings", + "claude-code.findings", "mesh-healers.attempts", - "mesh-delivery-assigner.wants", - "node-engine.have", - "mesh-delivery-assigner.placement" + "node-engine.have" + ], + "invokes": [ + "mesh-delivery-assigner.assigner_read_wants", + "mesh-delivery-assigner.assigner_read_assignments", + "mesh-delivery-assigner.assigner_read_findings", + "mesh-delivery-indexer.indexer_read_findings", + "seat:mesh-hand-acts.hand-act" + ], + "consumes": [ + "mesh-delivery-assigner.views.wants", + "mesh-delivery-assigner.views.assignments", + "mesh-delivery-assigner.views.findings", + "mesh-delivery-indexer.views.findings" ], "state": [ "conditions", diff --git a/modules/mesh-config/README.md b/modules/mesh-config/README.md index e5fffe1a8..5ed62e898 100644 --- a/modules/mesh-config/README.md +++ b/modules/mesh-config/README.md @@ -21,7 +21,7 @@ account alone writes its buckets. ## How a value resolves -For every module the seat mesh-placement places on a node (`mesh-delivery-assigner.placement`), from the module's +For every module the seat mesh-module-assigner places on a node (`mesh-delivery-assigner.assignments`), from the module's own manifests as the delivery built them, never from the controller (issue 1953): the trunk's, which the indexer's `modules.` names in its manifests by digest (`mesh-delivery-indexer.modules`, `.manifests`), with the build the node's want names (`mesh-delivery-assigner.wants`, the builder's `mesh-delivery-builder.builds` naming its manifest) @@ -30,7 +30,7 @@ module none of whose manifests can be read is listed under `unresolved` with why values last resolved for it. Then: the manifest's default; mesh config for the predefined key the value falls back to; node config for that key on the node; the module's own value on that node, the most specific winning. A value naming no predefined key resolves from its default and the module's own value only (ADR 0348 §2). It resolves at -start, whenever a placement, a want or a trunk module moves, and every five minutes, writing only the values that moved. +start, whenever an assignment, a want or a trunk module moves, and every five minutes, writing only the values that moved. ## The verb @@ -56,6 +56,26 @@ value as a set does, keeps who approved it beside the value (`approved`), and as warrant in its hand-act log. A Decline, an expiry or the router's refusal sets nothing. `action` `proposals` lists them, newest first; with `proposal`, one read whole, with whether the router's ask is the one composed from it. +## A value renamed, a value no longer read (design 32, novox/hq ADR 0609) + +- A declaration's `renamed-from` moves the value set under the old name to the new one on each node, with its trust (it + moves whole, and a trusted new name over a value set without the Approve is refused), saying `renamed` + (module, from, to, at) once. A **rename window** opens per node and keeps the old name answering the new value in + `mesh-config_resolved` until that node's node-engine runs a build that reads the new name: judged at start, on the + status view's `delivered`, and for a returning node-engine on the node-engine seat's `manifest-reads-changed` after + `manifest-reads` answers (one that does not serve it states nothing, and its window stays open). State `renames`. +- The seat verb `renames` answers the open renames and the nodes holding each open (the checker asks it); the tools + `config_renames` (all, with the closed windows), `config_rename_again` (move by hand after a refusal) and + `config_rename_close` (drop a node by hand, a hand act). +- On the seat mesh-catalog's `manifest-accepted` it asks the indexer's `indexer_config_readers`; an indexer that does not serve + it answers "not known" and nothing retires. A value (or secret) nobody reads is **retiring** once no node runs a + build that reads it: `retiring` is said and the operator is asked once; the value is removed only on the Approve + (a secret through the seat mesh-vault's `retire`, with a hand act naming node, module, name and ask), `retired` + said per node. State `retiring`; the tool `config_retiring` lists, asks again and cancels. +- Findings (state `findings`, read by the condition keeper): `rename-refused`, `rename-window-held` and + `retirement-held` (seven days, naming the node), `retiring-unanswered` (asked twice). +- The events `renamed`, `retiring` and `retired` are carried by the stream `config-events`, which keeps its history (the bus refuses a new event when it is full). + ## Files it does not know Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416). diff --git a/modules/mesh-config/cmd/mesh-config/config.go b/modules/mesh-config/cmd/mesh-config/config.go index 1db9388f7..a745180e4 100644 --- a/modules/mesh-config/cmd/mesh-config/config.go +++ b/modules/mesh-config/cmd/mesh-config/config.go @@ -93,6 +93,10 @@ type Config struct { Asks AskBus // Forge is the forge as pulls are asked of it (pulls.go); nil, a merge or a close is heard from its events alone. Forge PullForge + // Delivery is what the delivery parts answer (renames.go, retiring.go); nil, no window closes and nothing retires. + Delivery Delivery + // Vault is the seat mesh-vault, for the retiring of a secret; nil, a secret does not retire. + Vault Vault // RouterWithin and RouterEvery bound how long a proposal waits for the router's word on its ask, and how often // it looks; zero is RouterAnswersWithin and RouterAnswersEvery. RouterWithin, RouterEvery time.Duration diff --git a/modules/mesh-config/cmd/mesh-config/config_test.go b/modules/mesh-config/cmd/mesh-config/config_test.go index 7201384ac..a56661686 100644 --- a/modules/mesh-config/cmd/mesh-config/config_test.go +++ b/modules/mesh-config/cmd/mesh-config/config_test.go @@ -4,13 +4,16 @@ import ( "encoding/json" "errors" "fmt" + assignerclient "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + meshseat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "sort" "strings" "sync" "testing" "time" - - "git.novox.be/novox/mesh-sdk/go/delivery" ) // memStore is the bus as mesh-config reaches it, in memory: its states and the mirrors it reads, its history log, @@ -20,6 +23,7 @@ type memStore struct { states map[string]map[string][]byte logs map[string][]LogEntry events []Change + said []saidEvent handActs []string // failHandAct makes the hand-act log refuse. failHandAct bool @@ -89,15 +93,38 @@ func (m *memStore) Read(log string) ([]LogEntry, error) { } func (m *memStore) Emit(event string, body any) error { - if event != ConfigChanged { - return fmt.Errorf("mesh-config emits no %s", event) - } m.mu.Lock() defer m.mu.Unlock() - m.events = append(m.events, body.(Change)) + switch event { + case ConfigChanged: + m.events = append(m.events, body.(Change)) + case EventRenamed, EventRetiring, EventRetired: + m.said = append(m.said, saidEvent{event, body}) + default: + return fmt.Errorf("mesh-config emits no %s", event) + } return nil } +// saidEvent is an event of the family config.events as it was emitted. +type saidEvent struct { + Event string + Body any +} + +// saidOf is the events of one name emitted so far. +func (m *memStore) saidOf(event string) []any { + m.mu.Lock() + defer m.mu.Unlock() + var out []any + for _, e := range m.said { + if e.Event == event { + out = append(out, e.Body) + } + } + return out +} + func (m *memStore) HandAct(what, why, cause string) error { m.mu.Lock() defer m.mu.Unlock() @@ -122,62 +149,53 @@ func (m *memStore) mirror(t *testing.T, read, key, raw string) { m.states[read][key] = []byte(raw) } -// assign places a module on a node, in the seat mesh-placement's key for the node. +// assign places a module on a node, in the seat mesh-module-assigner's key for the node. func (m *memStore) assign(t *testing.T, node, module string) { t.Helper() m.place(t, node, module, true) } -// unassign takes a module off a node's placement. +// unassign takes a module off a node's assignments. func (m *memStore) unassign(t *testing.T, node, module string) { t.Helper() m.place(t, node, module, false) } -// place writes a node's placement as the seat writes it: its node key and module keys (research 072 row 37), and for -// ace in the form before, `placement.ace`, which a reader still takes while the change is delivered. +// place writes a node's assignments as the seat writes it: its node key and module keys (research 072 row 37). func (m *memStore) place(t *testing.T, node, module string, on bool) { t.Helper() keys := map[string][]byte{} - for _, k := range m.keysOf(PlacementRead) { - e, _ := m.Get(PlacementRead, k) + for _, k := range m.keysOf(AssignmentsRead) { + e, _ := m.Get(AssignmentsRead, k) keys[k] = e.Value } - all, err := delivery.PlacementsOf(keys) + all, err := assignerclient.AssignmentsOf(keys) if err != nil { t.Fatal(err) } p := all[node] p.Node = node - var kept []delivery.PlacedModule + var kept []meshseat.PlacedModule for _, x := range p.Modules { if x.Module != module { kept = append(kept, x) } } if on { - kept = append(kept, delivery.PlacedModule{Module: module}) + kept = append(kept, meshseat.PlacedModule{Module: module}) } p.Modules = kept - for _, k := range m.keysOf(PlacementRead) { - if n, _, _, err := delivery.ParsePlacementKey(k); err == nil && n == node { - _ = m.Delete(PlacementRead, k) + for _, k := range m.keysOf(AssignmentsRead) { + if n, _, _, err := assignerclient.ParseKey(k); err == nil && n == node { + _ = m.Delete(AssignmentsRead, k) } } - if node == "ace" { - raw, err := delivery.EncodePlacement(p) - if err != nil { - t.Fatal(err) - } - _ = m.Put(PlacementRead, "placement.ace", json.RawMessage(raw)) - return - } - values, err := delivery.PlacementValues(p) + values, err := assignerclient.AssignmentsValues(p) if err != nil { t.Fatal(err) } for k, raw := range values { - if err := m.Put(PlacementRead, k, json.RawMessage(raw)); err != nil { + if err := m.Put(AssignmentsRead, k, json.RawMessage(raw)); err != nil { t.Fatal(err) } } @@ -194,8 +212,8 @@ func (m *memStore) manifest(t *testing.T, module, raw string) string { if !json.Valid([]byte(raw)) { t.Fatalf("not JSON: %s", raw) } - digest := delivery.ManifestDigest([]byte(raw)) - value, err := delivery.EncodeManifest(delivery.Manifest{Module: module, JSON: raw}) + digest := catalogclient.ManifestDigest([]byte(raw)) + value, err := catalogclient.EncodeManifest(catalog.ManifestsByDigestValue{Module: module, Manifest: raw}) if err != nil { t.Fatal(err) } @@ -215,7 +233,7 @@ func (m *memStore) declare(t *testing.T, module, raw string) { func (m *memStore) want(t *testing.T, node, module, fp, raw string) { t.Helper() digest := m.manifest(t, module, raw) - key, err := delivery.BuilderBuildKey(module, fp) + key, err := builds.BuilderBuildKey(module, fp) if err != nil { t.Fatal(err) } @@ -565,7 +583,7 @@ func TestAListDroppingAKeyThatHoldsAValueIsWithheldUntilItIsCleared(t *testing.T } } -// **A placement, a declaration or its removal moves the resolved values**, written only where they moved, and +// **An assignment, a declaration or its removal moves the resolved values**, written only where they moved, and // said as config-changed; a pass over unchanged sources writes nothing. func TestAnAssignmentMovesTheResolvedValuesAndAQuietPassWritesNothing(t *testing.T) { c, m := aMesh(t) diff --git a/modules/mesh-config/cmd/mesh-config/declared.go b/modules/mesh-config/cmd/mesh-config/declared.go index 205c215d3..e8728b751 100644 --- a/modules/mesh-config/cmd/mesh-config/declared.go +++ b/modules/mesh-config/cmd/mesh-config/declared.go @@ -41,6 +41,9 @@ type Declaration struct { Predefined string `json:"predefined,omitempty"` Required bool `json:"required,omitempty"` Runtime bool `json:"runtime,omitempty"` + // RenamedFrom is the key's old name when it was renamed (novox/hq ADR 0609 §4, design 32): on the merge the value + // set under the old name moves to this key (renames.go). + RenamedFrom string `json:"renamed-from,omitempty"` } // HasDefault is whether the declaration gives a default: present and not null. @@ -145,7 +148,7 @@ type Published struct { Commit string `json:"commit"` } -// Assignment is a module placed on a node, as the seat mesh-placement's key `placement.` names it. +// Assignment is a module placed on a node, as the seat mesh-module-assigner's key `.node` names it. type Assignment struct { Node string `json:"node"` Module string `json:"module"` diff --git a/modules/mesh-config/cmd/mesh-config/delivery.go b/modules/mesh-config/cmd/mesh-config/delivery.go new file mode 100644 index 000000000..9aa2b9bc0 --- /dev/null +++ b/modules/mesh-config/cmd/mesh-config/delivery.go @@ -0,0 +1,132 @@ +package main + +// What mesh-config asks of the delivery parts and the vault, through their tools and seat verbs on the bus: never a +// bucket of theirs (novox/hq ADR 0596). Each key is the address a module's `invokes` names (mesh-sdk calladdress). + +import ( + "encoding/json" + "fmt" + engineclient "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "strings" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" +) + +// The addresses asked. +const ( + // HavesAsk is the status view's read of the node-engines' haves, narrowed to one node and module. + HavesAsk = "mesh-delivery-view.node_haves" + // ManifestReadsAsk is the node-engine seat's verb saying what manifest forms a node-engine reads (ADR 0618 §3), asked + // of one node by the address's `@`. + ManifestReadsAsk = "seat:node-engine.manifest-reads" + // ConfigReadersAsk is the indexer's own tool saying who reads a module's config keys (design 32). + ConfigReadersAsk = "mesh-delivery-indexer.indexer_config_readers" + // VaultRetireAsk is the seat mesh-vault's verb retiring one module's own secret on a node (design 32). + VaultRetireAsk = "seat:mesh-vault.retire" +) + +// toolDelivery answers Delivery through the bus. +type toolDelivery struct { + ask func(key string, body any) (json.RawMessage, error) +} + +// Have asks node_haves for one node and module and reads the fingerprint the node runs. +func (d toolDelivery) Have(node, module string) (string, bool, error) { + raw, err := d.ask(HavesAsk, map[string]any{"node": node, "module": module}) + if err != nil { + return "", false, err + } + answer, output, ok := unwrapAnswer(raw) + if !ok { + return "", false, fmt.Errorf("node_haves refused: %s", output) + } + var kept struct { + Keys map[string]json.RawMessage `json:"keys"` + } + if err := json.Unmarshal(answer, &kept); err != nil { + return "", false, fmt.Errorf("node_haves answered what is not its listing: %s", output) + } + key, err := engineclient.HaveKey(node, module) + if err != nil { + return "", false, err + } + have, ok := kept.Keys[key] + if !ok { + return "", false, nil + } + h, err := engineclient.DecodeHave(have) + if err != nil { + return "", false, fmt.Errorf("the have of %s on %s is not readable: %w", module, node, err) + } + return h.Fp, true, nil +} + +// Reads asks a node's node-engine what it reads; an answer means it states it. +func (d toolDelivery) Reads(node string) error { + _, err := d.ask(ManifestReadsAsk+"@"+node, map[string]any{}) + return err +} + +// Readers asks the indexer who reads a module's config keys; an indexer that does not serve the tool yet is "not known". +func (d toolDelivery) Readers(module string) ([]ReaderRow, bool, error) { + raw, err := d.ask(ConfigReadersAsk, map[string]any{"module": module}) + if err != nil { + if notServed(err) { + return nil, false, nil + } + return nil, false, err + } + answer, output, ok := unwrapAnswer(raw) + if !ok { + if notServed(fmt.Errorf("%s", output)) { + return nil, false, nil + } + return nil, false, fmt.Errorf("indexer_config_readers refused: %s", output) + } + var a struct { + Readers []ReaderRow `json:"readers"` + } + if err := json.Unmarshal(answer, &a); err != nil { + return nil, false, fmt.Errorf("indexer_config_readers answered what is not its listing: %s", output) + } + return a.Readers, true, nil +} + +// notServed is whether an error says the address has no holder serving it. +func notServed(err error) bool { + m := strings.ToLower(err.Error()) + for _, w := range []string{"no responders", "not served", "unknown tool", "no such tool", "serves no"} { + if strings.Contains(m, w) { + return true + } + } + return false +} + +// toolVault asks the seat mesh-vault to retire a secret: the hand act that warrants it is recorded first, by this +// module, naming the node, module, secret and the ask of the Approve; the vault reads it back by its id (`warrant`). +type toolVault struct { + ask func(key string, body any) (json.RawMessage, error) + recorder meshhandactsclient.Recorder +} + +// Retire records the warrant and calls the vault's verb. +func (v toolVault) Retire(r RetireRequest) error { + taken, err := v.recorder.Record(meshhandactsclient.Request{What: r.Node + " " + r.Module + " " + r.Name + " " + r.Ask, + Why: "the operator approved retiring " + r.Module + "'s secret " + r.Name + " on " + r.Node + " (ask " + r.Ask + ")", + Cause: "the operator's Approve of ask " + r.Ask, Verb: "mesh-vault.retire"}) + if err != nil { + return err + } + answer, _, _ := unwrapAnswer(taken.Answer) + var rec struct { + Act struct { + ID string `json:"id"` + } `json:"act"` + } + if json.Unmarshal(answer, &rec) != nil || rec.Act.ID == "" { + return fmt.Errorf("mesh-hand-acts recorded the warrant and named no act") + } + _, err = v.ask(VaultRetireAsk, map[string]any{"node": r.Node, "module": r.Module, "name": r.Name, "warrant": rec.Act.ID, "ask": r.Ask}) + return err +} diff --git a/modules/mesh-config/cmd/mesh-config/findings.go b/modules/mesh-config/cmd/mesh-config/findings.go new file mode 100644 index 000000000..3f9c1482d --- /dev/null +++ b/modules/mesh-config/cmd/mesh-config/findings.go @@ -0,0 +1,139 @@ +package main + +// mesh-config's findings (novox/hq ADR 0564 clause 2): what it sees wrong about a rename or a retirement, kept in its +// state `findings` as the condition keeper reads them, written when what they say changes and deleted when the fault +// is gone. The keeper raises and clears the condition of the same key (mesh-conditions' Observers and Homes). + +import ( + "fmt" + "slices" + "strings" + "time" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" +) + +// FindingsState is the state, meshconditions.StateFindings. +const FindingsState = meshconditions.StateFindings + +// The kinds of finding mesh-config writes. +const ( + // FindingRenameRefused is a move mesh-config refused; cleared once the value moved or `renamed-from` left the + // trunk's manifest. + FindingRenameRefused = "rename-refused" + // FindingWindowHeld is a rename window held open seven days by a node; cleared when the window closes. + FindingWindowHeld = "rename-window-held" + // FindingRetireUnanswered is a retirement's ask unanswered after it was asked once more; cleared by an answer, a + // cancel, or the value read again. + FindingRetireUnanswered = "retiring-unanswered" + // FindingRetireHeld is a retirement waiting seven days for a node still running a build that reads the value. + FindingRetireHeld = "retirement-held" +) + +// findingKey is the key of a finding of kind about a module on a node: the condition's key. +func findingKey(kind, module, node, ref string) string { + id := module + if node != "" { + id += "." + node + } + return meshconditionsclient.Key(meshconditionsclient.ScopeModule, id, kind+"-"+ref) +} + +func finding(kind, module, node, ref, summary, headline, explanation, needs, resolved, said string, at time.Time) meshconditions.FindingsByKeyValue { + id := module + if node != "" { + id += "." + node + } + return meshconditions.FindingsByKeyValue{V: meshconditionsclient.FindingVersion, Key: findingKey(kind, module, node, ref), Kind: kind, Ref: kind + "-" + ref, + Subject: meshconditions.Subject{Scope: meshconditionsclient.ScopeModule, ID: id, Machine: node}, + Severity: meshconditionsclient.Warning, Resolver: meshconditionsclient.ResolverOperator, Summary: summary, Headline: headline, + Explanation: explanation, Needs: needs, Resolved: resolved, + Evidence: []meshconditions.Evidence{{At: at.UTC(), Said: said}}, Source: "config", Observer: Module, At: at.UTC()} +} + +// renameRefused is the finding of a move mesh-config refused. +func renameRefused(module, to, from, why string, at time.Time) meshconditions.FindingsByKeyValue { + return finding(FindingRenameRefused, module, "", to, + fmt.Sprintf("the rename of %s's config value %s to %s is refused: %s", module, from, to, why), + fmt.Sprintf("%s's value %s is not moved to %s", module, from, to), + fmt.Sprintf("The new version of %s names %s as the new name of its config value %s, and mesh-config refused to move the value: %s. Nothing was moved.", module, to, from, why), + fmt.Sprintf("put the cause right, then move it with config_rename_again (module %s, to %s); the details name it.", module, to), + fmt.Sprintf("Resolved: %s's value %s is under %s", module, from, to), why, at) +} + +// windowHeld is the finding of a rename window held open past HeldFinding by a node. +func windowHeld(r Rename, node string, at time.Time) meshconditions.FindingsByKeyValue { + return finding(FindingWindowHeld, r.Module, node, r.To, + fmt.Sprintf("%s on %s has held the old config name %s open for %d days", r.Module, node, r.From, int(HeldFinding.Hours()/24)), + fmt.Sprintf("%s on %s still runs the old config name", r.Module, node), + fmt.Sprintf("The rename of %s's config value %s to %s is not complete on %s: its node-engine has not reported a build that reads %s (it may be away).", r.Module, r.From, r.To, node, r.To), + fmt.Sprintf("bring %s back, or drop it from the window with config_rename_close.", node), + fmt.Sprintf("Resolved: %s reads %s on %s", r.Module, r.To, node), + fmt.Sprintf("the window opened %s and %s has not reported a build reading %s", r.Nodes[node].Since.UTC().Format(time.RFC3339), node, r.To), at) +} + +// setFinding writes a finding when it differs from the one kept. +func (c *Config) setFinding(f meshconditions.FindingsByKeyValue) { + if err := meshconditionsclient.CheckFinding(f); err != nil { + c.logf("[mesh-config] a finding is not valid: %v", err) + return + } + if e, err := c.Store.Get(FindingsState, f.Key); err == nil && e != nil { + if old, derr := meshconditions.DecodeFindingsByKeyValue(e.Value); derr == nil && old.Summary == f.Summary { + return // what it says has not changed + } + } + if err := c.Store.Put(FindingsState, f.Key, f); err != nil { + c.logf("[mesh-config] the finding %s is not written: %v", f.Key, err) + } +} + +// clearFinding deletes the findings of a kind and ref about a module, on whatever node but those in keep. +func (c *Config) clearFinding(kind, module, ref string, keep ...string) { + keys, err := c.Store.Keys(FindingsState) + if err != nil { + return + } + want := kind + "-" + ref + for _, k := range keys { + e, err := c.Store.Get(FindingsState, k) + if err != nil || e == nil { + continue + } + f, err := meshconditions.DecodeFindingsByKeyValue(e.Value) + if err != nil || f.Ref != want || !(f.Subject.ID == module || strings.HasPrefix(f.Subject.ID, module+".")) || + (f.Subject.Machine != "" && slices.Contains(keep, f.Subject.Machine)) { + continue + } + if err := c.Store.Delete(FindingsState, k); err != nil { + c.logf("[mesh-config] the finding %s is not cleared: %v", k, err) + } + } +} + +// retirementHeld is the finding of a retirement that waits seven days for nodes that still run a build reading the value. +func retirementHeld(e Retiring, at time.Time) meshconditions.FindingsByKeyValue { + node := "" + if len(e.Nodes) > 0 { + node = e.Nodes[0] + } + return finding(FindingRetireHeld, e.Module, node, e.Key, + fmt.Sprintf("%s's %s has waited %d days to retire: %s still run a build that reads it", e.Module, e.Key, int(HeldFinding.Hours()/24), strings.Join(e.Nodes, ", ")), + fmt.Sprintf("%s's %s cannot be retired yet", e.Module, e.Key), + fmt.Sprintf("No manifest reads %s's %s any more, but %s still run a build that does, so the operator is not asked to remove it.", e.Module, e.Key, strings.Join(e.Nodes, ", ")), + "bring those nodes up to date, or cancel the retirement with config_retiring.", + fmt.Sprintf("Resolved: no node reads %s's %s", e.Module, e.Key), + "retiring since "+e.Since.UTC().Format(time.RFC3339)+", still read on "+strings.Join(e.Nodes, ", "), at) +} + +// retireUnanswered is the finding of a retirement asked twice and not answered. +func retireUnanswered(e Retiring, at time.Time) meshconditions.FindingsByKeyValue { + return finding(FindingRetireUnanswered, e.Module, "", e.Key, + fmt.Sprintf("the operator did not answer the ask to remove %s's %s, asked twice", e.Module, e.Key), + fmt.Sprintf("%s's %s waits for an answer", e.Module, e.Key), + fmt.Sprintf("No manifest reads %s's %s any more. The operator was asked twice whether to remove it and did not answer, so it stays.", e.Module, e.Key), + "answer the ask again with config_retiring ask-again, or cancel it.", + fmt.Sprintf("Resolved: %s's %s is answered or read again", e.Module, e.Key), + "asked "+e.Asked.UTC().Format(time.RFC3339)+" and once more", at) +} diff --git a/modules/mesh-config/cmd/mesh-config/main.go b/modules/mesh-config/cmd/mesh-config/main.go index 93b271a80..16a839755 100644 --- a/modules/mesh-config/cmd/mesh-config/main.go +++ b/modules/mesh-config/cmd/mesh-config/main.go @@ -4,12 +4,13 @@ // node), mesh-config_resolved (each module's value on each node, with its source), mesh-config_predefined (the // published list of predefined keys) and mesh-config_proposals (each value proposed for the operator's Approve, // asked under this module's own name, design 57 K4). -// It resolves from where the seat mesh-placement places each module, and each module's declarations as its own +// It resolves from where the seat mesh-module-assigner places each module, and each module's declarations as its own // manifests declare them, the trunk's and its wanted build's (the indexer's and the builder's state, issue 1953); the // list of predefined keys comes from the indexer's (ADR 0565 §2); the controller's records are no longer read (issue 774). stdout is the MCP channel; what this module says, it says on stderr. package main import ( + "context" "encoding/json" "errors" "fmt" @@ -17,9 +18,10 @@ import ( "strings" "time" + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/asks" - "git.novox.be/novox/mesh-sdk/go/handacts" ) // Seat is the role this module holds, once for the mesh, and Verb its one verb. @@ -30,10 +32,19 @@ const ( func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) } -// runtimeStore is the bus, through the runtime that launched this bundle. +// theViews are the pictures of the delivery owners' state, made when the module runs (nil in a test of the tools). +var theViews *ownerViews + +// runtimeStore is the bus, through the runtime that launched this bundle. A state of the delivery owners that the +// owners' tools answer is read from the picture those keep (ownerviews.go), the rest from the bus. type runtimeStore struct{} func (runtimeStore) Get(state, key string) (*Entry, error) { + if theViews != nil { + if e, followed, err := theViews.get(state, key); followed { + return e, err + } + } e, err := stdio.State(state).Get(key) if err != nil || e == nil { return nil, err @@ -48,7 +59,14 @@ func (runtimeStore) Put(state, key string, value any) error { func (runtimeStore) Delete(state, key string) error { return stdio.State(state).Delete(key) } -func (runtimeStore) Keys(state string) ([]string, error) { return stdio.State(state).Keys() } +func (runtimeStore) Keys(state string) ([]string, error) { + if theViews != nil { + if keys, followed, err := theViews.keys(state); followed { + return keys, err + } + } + return stdio.State(state).Keys() +} func (runtimeStore) Append(log, key string, value any) error { _, err := stdio.Log(log).Append(key, value) @@ -76,11 +94,11 @@ func (runtimeStore) Emit(event string, body any) error { return stdio.Emit(event // recorder records into the hand-act log of the seat mesh-hand-acts alone (novox/hq ADR 0565 §11, issue 1953): the manifest // invokes no verb of the controller and the SDK recorder has no fall back to its log. -var recorder = handacts.Recorder{Ask: stdio.Ask} +var recorder = meshhandactsclient.Recorder{Ask: stdio.Ask} // HandAct records a change in the hand-act log (to-be 45 §7). func (runtimeStore) HandAct(what, why, cause string) error { - _, err := recorder.Record(handacts.Request{What: what, Why: why, Cause: cause}) + _, err := recorder.Record(meshhandactsclient.Request{What: what, Why: why, Cause: cause}) return err } @@ -100,9 +118,19 @@ func (runtimeAsks) Warranted(ask string) error { } func main() { - c := &Config{Store: runtimeStore{}, Logf: logf, Asks: runtimeAsks{}, Forge: toolForge{ask: stdio.Ask}} - go resolving(c) + c := &Config{Store: runtimeStore{}, Logf: logf, Asks: runtimeAsks{}, Forge: toolForge{ask: stdio.Ask}, + Delivery: toolDelivery{ask: stdio.Ask}, Vault: toolVault{ask: stdio.Ask, recorder: recorder}} + moved := make(chan struct{}, 1) + theViews = newOwnerViews(stdio.Ask, func() { + select { + case moved <- struct{}{}: + default: + } + }, meshconditionsclient.FindingWriter{State: stdio.State(FindingsState)}) + theViews.Start(context.Background()) + go resolving(c, moved) go hearing(c) + go hearingDelivery(c) if err := stdio.Serve("", Tools(c)); err != nil { logf("%v", err) os.Exit(1) @@ -112,22 +140,9 @@ func main() { // resolving resolves at start and again whenever a source moves, and every five minutes besides, so a change // heard nowhere is still resolved. The runtime answers only once Serve runs, so the watches are asked again // until they are taken. -func resolving(c *Config) { - moved := make(chan struct{}, 1) - poke := func(stdio.StateChange) error { - select { - case moved <- struct{}{}: - default: - } - return nil - } - // A build's manifest is written before a want names the build, so a want, a trunk module or a placement moving is - // every change of what a module declares where (issue 1953); the builds themselves are read, not watched. - for _, read := range []string{PlacementRead, WantsRead, ModulesRead, IndexerPredefinedRead, - CandidatesRead} { - // Asked again until the runtime takes it, each give-up said (mesh-sdk's Follow). - go stdio.State(read).Follow("", poke) - } +func resolving(c *Config, moved chan struct{}) { + // The owners' states reach `moved` through their views (ownerviews.go); the builder's builds, which has no tool yet, + // is read when a want moves, and nothing else is watched. tick := time.NewTicker(5 * time.Minute) defer tick.Stop() time.Sleep(time.Second) @@ -168,8 +183,8 @@ func hearing(c *Config) { go func() { <-stdio.Served() // subscribed as soon as the runtime serves, never after a guessed wait: an event sent meanwhile is heard for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { - err := stdio.Subscribe(asks.Seat+".decided."+Module, func(e stdio.Envelope) error { - var w asks.Warrant + err := stdio.Subscribe(oc.Seat+".decided."+Module, func(e stdio.Envelope) error { + var w oc.Warrant if json.Unmarshal(e.Body, &w) == nil { for _, s := range c.HeardEnd(w) { logf("[mesh-config] %s", s) @@ -221,9 +236,90 @@ func hearing(c *Config) { } } +// hearingDelivery takes the delivery parts' words that close a rename window or retire a value, each asked again until +// the runtime takes the subscription: the status view's `delivered`, the node-engine seat's `manifest-reads-changed` +// (a returning node-engine; design 32) and the seat mesh-catalog's `manifest-accepted`. At its start it judges the windows once, +// since an event said while this module was down is not said again. +func hearingDelivery(c *Config) { + <-stdio.Served() + for _, h := range []struct { + pattern string + handle func(stdio.Envelope) []string + }{ + {"mesh-delivery-view.delivered", func(stdio.Envelope) []string { return c.Windows("") }}, + {"node-engine.manifest-reads-changed", func(e stdio.Envelope) []string { + var b struct { + Node string `json:"node"` + } + _ = json.Unmarshal(e.Body, &b) + if b.Node == "" { + // The event's subject ends with the node's token (ADR 0618 as built): mesh.seat.node-engine.event.manifest-reads-changed.. + b.Node = e.Key[strings.LastIndex(e.Key, ".")+1:] + } + return c.Windows(b.Node) + }}, + {"mesh-catalog.manifest-accepted", func(e stdio.Envelope) []string { + var b struct { + Module string `json:"module"` + } + if json.Unmarshal(e.Body, &b) != nil { + return nil + } + return c.Retirements(b.Module) + }}, + } { + go func(pattern string, handle func(stdio.Envelope) []string) { + for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { + err := stdio.Subscribe(pattern, func(e stdio.Envelope) error { + for _, s := range handle(e) { + logf("[mesh-config] %s", s) + } + return nil + }) + if err == nil { + return + } + logf("[mesh-config] does not hear %s yet (%v); asking again in %s", pattern, err, wait) + time.Sleep(wait) + } + }(h.pattern, h.handle) + } + for _, s := range c.Windows("") { + logf("[mesh-config] %s", s) + } +} + +// seatRename is one open rename as the seat's definition answers it: module, from, to, at and the nodes holding it open. +type seatRename struct { + Module string `json:"module"` + From string `json:"from"` + To string `json:"to"` + At time.Time `json:"at"` + Open []string `json:"open"` +} + +func seatRenames(rows []RenameRow) []seatRename { + out := make([]seatRename, 0, len(rows)) + for _, r := range rows { + out = append(out, seatRename{Module: r.Module, From: r.From, To: r.To, At: r.At, Open: r.Open}) + } + return out +} + // Tools is the seat's verb and the module's own tools of the switch, as the runtime serves them. func Tools(c *Config) []stdio.Tool { - return append(switchTools(c), stdio.Tool{ + renames := stdio.Tool{Name: Seat + "." + RenamesVerb, + Description: "The open renames of config values: for each, the module, the old and the new name, and the nodes " + + "still holding the old name open (the rename window). The checker asks it before it lets a manifest add or " + + "drop renamed-from (novox/hq design 32). Narrowed by module.", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "module": map[string]any{"type": "string", "description": "one module"}}}, + Run: func(a map[string]any) (any, error) { + m, _ := a["module"].(string) + rows, err := c.Renames(strings.TrimSpace(m), true) + return map[string]any{"renames": seatRenames(rows)}, err + }} + return append(append(switchTools(c), viewTools(c, func() *ownerViews { return theViews })...), renames, stdio.Tool{ Name: Seat + "." + Verb, Description: "Set, clear, list or read the history of config values (novox/hq ADR 0348). A value is set at " + "one level: mesh config (no module, no node) or node config (a node) for a predefined key, or a " + diff --git a/modules/mesh-config/cmd/mesh-config/manifest_test.go b/modules/mesh-config/cmd/mesh-config/manifest_test.go index 7c3b73a96..2126b955c 100644 --- a/modules/mesh-config/cmd/mesh-config/manifest_test.go +++ b/modules/mesh-config/cmd/mesh-config/manifest_test.go @@ -4,8 +4,11 @@ import ( "encoding/json" "os" "path/filepath" + "slices" "strings" "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" ) // Every state, log and read the manifest declares has a tool that reads it, and every act the module takes by itself @@ -16,11 +19,12 @@ func TestEveryStateAndActHasItsTool(t *testing.T) { t.Fatal(err) } var m struct { - State []string `json:"state"` - Logs []string `json:"logs"` - Reads []string `json:"reads"` - Tools []string `json:"tools"` - Claims []struct { + State []string `json:"state"` + Packages map[string]string `json:"packages"` + Logs []string `json:"logs"` + Reads []string `json:"reads"` + Tools []string `json:"tools"` + Claims []struct { Name string `json:"name"` Serves []string `json:"serves"` } `json:"claims"` @@ -28,13 +32,21 @@ func TestEveryStateAndActHasItsTool(t *testing.T) { if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } + // The builder's keys are read through its published package, taken by its series (ADR 0634 §1). + if m.Packages["builds"] != "0.1" { + t.Errorf("packages %v: want builds of series 0.1", m.Packages) + } via := map[string]string{ ValuesState: "config list", ResolvedState: "config list", PredefinedState: "config list", ProposalsState: "config proposals", PullsState: "config list", KeptState: "config list", HistoryLog: "config history", - DeclaredState: DeclaredTool, - PlacementRead: "config list", WantsRead: "config list", BuildsRead: "config list", ModulesRead: DeclaredTool, + DeclaredState: DeclaredTool, + AssignmentsRead: "config list", WantsRead: "config list", BuildsRead: "config list", ModulesRead: DeclaredTool, ManifestsRead: DeclaredTool, IndexerPredefinedRead: "config list", CandidatesRead: ResyncTool, "a resolution": ResyncTool, "the indexer's candidates taken": ResyncTool, + // design 32: each new state with the tool that reads it, each act with the tool that starts it. + RenamesState: RenamesTool, RetiringState: RetiringTool, FindingsState: "config_renames and config_retiring", + "a move refused": RenameAgainTool, "a window dropped": RenameCloseTool, "an ask again": RetiringTool, + "the owners' first views": FirstViewsTool, "a first view asked again": FirstViewAgainTool, } served := map[string]bool{} for _, tool := range Tools(&Config{}) { @@ -43,6 +55,19 @@ func TestEveryStateAndActHasItsTool(t *testing.T) { if len(m.Claims) != 1 || m.Claims[0].Name != Seat || strings.Join(m.Claims[0].Serves, ",") != strings.Join(SeatVerbs, ",") { t.Errorf("claims %+v, want %s serving %v", m.Claims, Seat, SeatVerbs) } + // The stream and its events are declared as the module emits them, and the manifest decodes whole. + if dm, err := catalogue.Decode(raw); err != nil { + t.Errorf("the manifest does not decode: %v", err) + } else { + // An event goes to one place (novox/hq ADR 0363 §2): config-changed to EVENTS, the rename window's to the stream. + if !slices.Equal(dm.Emits, []string{ConfigChanged}) { + t.Errorf("emits %v, want only %s: the stream carries the others", dm.Emits, ConfigChanged) + } + if len(dm.Streams) != 1 || dm.Streams[0].Name != StreamName || !dm.Streams[0].KeepsHistory() || !dm.Streams[0].Carries(EventRenamed) || + !dm.Streams[0].Carries(EventRetiring) || !dm.Streams[0].Carries(EventRetired) || dm.Streams[0].Carries(ConfigChanged) { + t.Errorf("streams %+v: want %s, keeping its history, carrying renamed, retiring and retired but not config-changed", dm.Streams, StreamName) + } + } for _, verb := range SeatVerbs { if !served[Seat+"."+verb] { t.Errorf("the seat's verb %s is not served", verb) @@ -53,6 +78,11 @@ func TestEveryStateAndActHasItsTool(t *testing.T) { t.Errorf("the manifest lists the tool %s, which is not served", name) } } + // The delivery owners' state is read through their tools and views (ADR 0615 §1): only the builder's builds, which has + // no tool yet, is still a read. + if len(m.Reads) != 1 || m.Reads[0] != BuildsRead { + t.Errorf("reads %v, want the builder's builds alone", m.Reads) + } for _, list := range [][]string{m.State, m.Logs, m.Reads} { for _, s := range list { if _, ok := via[s]; !ok { diff --git a/modules/mesh-config/cmd/mesh-config/ownerviews.go b/modules/mesh-config/cmd/mesh-config/ownerviews.go new file mode 100644 index 000000000..1eb15cf1f --- /dev/null +++ b/modules/mesh-config/cmd/mesh-config/ownerviews.go @@ -0,0 +1,243 @@ +package main + +// What mesh-config reads of the delivery owners' state, through their tools and views families (novox/hq ADR 0615 §1, +// §4 to §6, ADR 0596): never a bucket of theirs. For each of the six states it follows (the indexer's modules, manifests, +// predefined and config-candidates, the assigner's assignments and wants) it asks the owner's read tool for the whole +// state at its start, keeps the picture from the owner's `views.` events, and asks again only on a gap. Started +// while an owner is down it keeps asking, and past two minutes writes a finding naming the owner and the tool. +// +// The ordinary read tools are asked, not the first-view tools: those answer a few named callers (mesh-grants, +// node-tools, the delivery parts), and mesh-config is not one. The builder's `builds` has no owner tool yet (A2 built the +// indexer's and the assigner's), so it is still read as a `reads` entry. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "sort" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + + "git.novox.be/novox/mesh-sdk/go/views" +) + +// The tools of the owners that mesh-config asks, by the state they read. +const ( + IndexerModulesTool = "indexer_read_modules" + IndexerManifestsTool = "indexer_read_manifests" + IndexerPredefTool = "indexer_read_predefined" + IndexerCandidatesTool = "indexer_read_config_candidates" + AssignerAssignmentsTool = "assigner_read_assignments" + AssignerWantsTool = "assigner_read_wants" + + FirstViewsTool = "config_first_views" + FirstViewAgainTool = "config_first_view_again" +) + +// followed is each state read through its owner: the `.` name this module's code uses, the owner, its +// state and its tool. +var followed = []struct{ Read, Owner, State, Tool string }{ + {ModulesRead, indexerModule, "modules", IndexerModulesTool}, + {ManifestsRead, indexerModule, "manifests", IndexerManifestsTool}, + {IndexerPredefinedRead, indexerModule, catalog.StatePredefined, IndexerPredefTool}, + {CandidatesRead, indexerModule, catalog.StateConfigCandidates, IndexerCandidatesTool}, + {AssignmentsRead, assignerModule, "assignments", AssignerAssignmentsTool}, + {WantsRead, assignerModule, "wants", AssignerWantsTool}, +} + +// ownerViews are the pictures, by the read name. +type ownerViews struct { + by map[string]*views.Follower + // findings keeps the findings a first view unanswered writes. + findings views.FindingWriter +} + +// newOwnerViews makes a follower for each state; changed is told when any picture changes. +func newOwnerViews(ask func(key string, body any) (json.RawMessage, error), changed func(), findings views.FindingWriter) *ownerViews { + o := &ownerViews{by: map[string]*views.Follower{}, findings: findings} + for _, f := range followed { + f := f + fol := &views.Follower{Owner: f.Owner, Tool: f.Owner + "." + f.Tool, State: f.State, Logf: logf, + Ask: func(_ context.Context, after string) (views.Answer, error) { + body := map[string]any{"values": true} + if after != "" { + body["after"] = after + } + raw, err := ask(f.Owner+"."+f.Tool, body) + if err != nil { + return views.Answer{}, err + } + answer, output, ok := unwrapAnswer(raw) + if !ok { + return views.Answer{}, fmt.Errorf("%s refused: %s", f.Tool, output) + } + return views.DecodeAnswer(answer) + }, + Changed: func(string, json.RawMessage, bool, time.Time) { changed() }} + fol.Raise = func(s views.Status) { + _ = findings.Put(views.UnansweredFinding(Module, s, time.Now())) + } + fol.Clear = func(s views.Status) { + _ = findings.Delete(views.UnansweredFinding(Module, s, time.Now())) + } + o.by[f.Read] = fol + } + return o +} + +// Start asks every first view until answered (each on its goroutine) and subscribes to the owners' views events. +func (o *ownerViews) Start(ctx context.Context) { + for _, fol := range o.by { + go func(f *views.Follower) { + if err := f.Start(ctx); err != nil { + logf("[mesh-config] the first view of %s's %s ended unanswered: %v", f.Owner, f.State, err) + } + }(fol) + } + for _, f := range followed { + f := f + pattern := f.Owner + "." + views.EventName(f.State) + go func() { + <-stdio.Served() + for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { + err := stdio.Subscribe(pattern, func(e stdio.Envelope) error { + ev, err := views.DecodeEvent(e.Body) + if err != nil { + logf("[mesh-config] %s is not a views event: %v", pattern, err) + return nil + } + fol := o.by[f.Read] + if gap, why := fol.Apply(ev); gap { + logf("[mesh-config] a gap in %s's %s (%s): its first view is asked once more", f.Owner, f.State, why) + go func() { _ = fol.Start(ctx) }() + } + return nil + }) + if err == nil { + return + } + logf("[mesh-config] does not hear %s yet (%v); asking again in %s", pattern, err, wait) + time.Sleep(wait) + } + }() + } +} + +// read is the picture of a read name, and whether the name is one followed. +func (o *ownerViews) read(state string) (*views.Follower, bool) { + f, ok := o.by[state] + return f, ok +} + +// errUnanswered says a first view is not answered yet: a reader waits, and its next resolution reads it. +func errUnanswered(f *views.Follower) error { + s := f.Status() + return fmt.Errorf("the first view of %s's %s is not answered yet (%s asked %d times)", s.Owner, s.State, s.Tool, s.Asks) +} + +// get is one key of a followed state. +func (o *ownerViews) get(state, key string) (*Entry, bool, error) { + f, ok := o.read(state) + if !ok { + return nil, false, nil + } + select { + case <-f.Ready(): + default: + return nil, true, errUnanswered(f) + } + v, held := f.Get(key) + if !held { + return nil, true, nil + } + return &Entry{Key: key, Value: v}, true, nil +} + +// keys are the keys of a followed state, sorted. +func (o *ownerViews) keys(state string) ([]string, bool, error) { + f, ok := o.read(state) + if !ok { + return nil, false, nil + } + select { + case <-f.Ready(): + default: + return nil, true, errUnanswered(f) + } + var out []string + for k := range f.Values() { + out = append(out, k) + } + sort.Strings(out) + return out, true, nil +} + +// statuses are the first views' statuses, by owner and state. +func (o *ownerViews) statuses() []views.Status { + var out []views.Status + for _, f := range followed { + out = append(out, o.by[f.Read].Status()) + } + return out +} + +// again asks one first view once more by hand (".", or all when empty). +func (o *ownerViews) again(ctx context.Context, which, why string) ([]string, error) { + var done []string + for _, f := range followed { + if which != "" && which != f.Owner+"."+f.State { + continue + } + if err := o.by[f.Read].Again(ctx, why); err != nil { + return done, err + } + done = append(done, f.Owner+"."+f.State) + } + if len(done) == 0 { + return nil, errors.New("no first view is named " + which) + } + return done, nil +} + +// viewTools are the read tool of the first views and its re-read (design 25 § 12.2): the status of each, and a hand ask. +func viewTools(c *Config, o func() *ownerViews) []stdio.Tool { + return []stdio.Tool{ + {Name: FirstViewsTool, + Description: "mesh-config's first views of the delivery owners' state: for each of the indexer's modules, manifests, " + + "predefined and config-candidates and the assigner's assignments and wants, the tool it asks, the revision its " + + "picture holds, when it asked and was answered, how many asks (and by hand), and whether its condition is raised.", + Run: func(map[string]any) (any, error) { + ov := o() + if ov == nil { + return nil, errors.New("mesh-config reads its owners through views only when it runs as the module") + } + return map[string]any{"first-views": ov.statuses()}, nil + }}, + {Name: FirstViewAgainTool, + Description: "Ask a first view once more by hand, `.` (all when not given), recorded as a hand act with " + + "its why.", + Input: map[string]any{"type": "object", "required": []string{"why"}, "properties": map[string]any{ + "view": map[string]any{"type": "string", "description": "., such as mesh-delivery-assigner.wants"}, + "why": map[string]any{"type": "string"}}}, + Run: func(a map[string]any) (any, error) { + ov := o() + if ov == nil { + return nil, errors.New("mesh-config reads its owners through views only when it runs as the module") + } + why, _ := a["why"].(string) + view, _ := a["view"].(string) + if strings.TrimSpace(why) == "" { + return nil, errors.New("say why: a hand act keeps its why") + } + if err := c.Store.HandAct("config_first_view_again "+view, strings.TrimSpace(why), "config_first_view_again"); err != nil { + return nil, fmt.Errorf("the hand-act log does not hold it, so nothing is asked: %w", err) + } + done, err := ov.again(context.Background(), strings.TrimSpace(view), strings.TrimSpace(why)) + return map[string]any{"asked-again": done}, err + }}, + } +} diff --git a/modules/mesh-config/cmd/mesh-config/ownerviews_test.go b/modules/mesh-config/cmd/mesh-config/ownerviews_test.go new file mode 100644 index 000000000..d7611930e --- /dev/null +++ b/modules/mesh-config/cmd/mesh-config/ownerviews_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "strings" + "sync/atomic" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/views" +) + +// CAT-C11 (ADR 0615 §1): a followed state of the delivery owners is read from the picture of its owner's tool and events: +// a key asked before the first view is answered is an error (a reader waits), never a read of the owner's bucket; after +// it, the owner's value; an event at the next revision changes it, one past it is a gap and asks the first view again; a +// state that is no owner's falls through to the bus. +func TestAFollowedStateIsReadFromItsOwnersFirstViewAndEvents(t *testing.T) { + created := time.Date(2026, 11, 4, 9, 0, 0, 0, time.UTC) + var asks atomic.Int32 + hold := make(chan struct{}) + ask := func(key string, body any) (json.RawMessage, error) { + if key != "mesh-delivery-assigner.assigner_read_wants" || body.(map[string]any)["values"] != true { + return nil, errors.New("asked " + key) + } + <-hold + n := asks.Add(1) + a := views.Answer{V: 1, Owner: "mesh-delivery-assigner", State: "wants", Revision: uint64(n) * 10, Created: created, + Values: map[string]json.RawMessage{"want.g14.dunst": json.RawMessage(`{"fp":"a"}`)}} + raw, _ := json.Marshal(a) + return raw, nil + } + var changed atomic.Int32 + o := newOwnerViews(ask, func() { changed.Add(1) }, nopFindings{}) + if _, followed, err := o.get(WantsRead, "want.g14.dunst"); !followed || err == nil || !strings.Contains(err.Error(), "not answered yet") { + t.Fatalf("before the first view: followed %v, %v", followed, err) + } + if _, followed, _ := o.get(BuildsRead, "x"); followed { + t.Error("the builder's builds is followed, and it has no owner tool") + } + ctx := context.Background() + go func() { _ = o.by[WantsRead].Start(ctx) }() + hold <- struct{}{} + <-o.by[WantsRead].Ready() + e, isFollowed, err := o.get(WantsRead, "want.g14.dunst") + if err != nil || !isFollowed || e == nil || string(e.Value) != `{"fp":"a"}` { + t.Fatalf("after the first view: %+v, %v, %v", e, isFollowed, err) + } + ev := func(rev uint64, key string, deleted bool) views.Event { + return views.Event{V: 1, Owner: "mesh-delivery-assigner", State: "wants", Key: key, Revision: rev, Created: created, Deleted: deleted, + Value: json.RawMessage(`{"fp":"b"}`)} + } + if gap, why := o.by[WantsRead].Apply(ev(11, "want.novox.dunst", false)); gap { + t.Fatalf("the next revision is a gap: %s", why) + } + if keys, _, _ := o.keys(WantsRead); strings.Join(keys, ",") != "want.g14.dunst,want.novox.dunst" { + t.Errorf("keys %v", keys) + } + if gap, _ := o.by[WantsRead].Apply(ev(11, "want.novox.dunst", false)); gap { + t.Error("an event already in the picture is a gap") + } + if gap, why := o.by[WantsRead].Apply(ev(13, "want.x.y", false)); !gap || !strings.Contains(why, "changes missed") { + t.Errorf("a missed revision: gap %v, %s", gap, why) + } + o.by[WantsRead].Apply(ev(12, "want.g14.dunst", true)) + if e, _, _ := o.get(WantsRead, "want.g14.dunst"); e != nil { + t.Error("a deleted key is still held") + } + if changed.Load() < 3 { + t.Errorf("the resolution was told of %d changes", changed.Load()) + } + if got := o.statuses(); len(got) != len(followed) { + t.Errorf("statuses %d, want %d", len(got), len(followed)) + } +} + +// nopFindings takes a finding and keeps none. +type nopFindings struct{} + +func (nopFindings) Put(views.Finding) error { return nil } +func (nopFindings) Delete(views.Finding) error { return nil } diff --git a/modules/mesh-config/cmd/mesh-config/proposals.go b/modules/mesh-config/cmd/mesh-config/proposals.go index d6cb304df..d3e689dc9 100644 --- a/modules/mesh-config/cmd/mesh-config/proposals.go +++ b/modules/mesh-config/cmd/mesh-config/proposals.go @@ -16,8 +16,8 @@ package main // // **On the answer** mesh-config reads the router's own record of its ask (which only the router writes, and this // module reads under its own name alone), composes the ask again from the proposal and holds the warrant to it: -// the same ask digest, an option it offered, at its level, in time (asks.Warrant.For), and the act the option -// bound is this proposal's (asks.Option.Performs). A record whose value or shown value changed after it was asked +// the same ask digest, an option it offered, at its level, in time (oc.Warrant.For), and the act the option +// bound is this proposal's (oc.Option.Performs). A record whose value or shown value changed after it was asked // is refused, and so is an Approve when the value stored at the level is no longer the one the operator was shown // the change against. Then the value is set exactly as a set at the terminal is, with the same judgement and // history, and who approved it is kept beside the value; the warrant is recorded in the controller's hand-act log. @@ -34,8 +34,9 @@ import ( "strings" "time" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/outward" ) @@ -47,7 +48,7 @@ const ProposalVerb = Seat + "." + Verb // ProposalLasts is how long the operator has to answer: a day less ten minutes (ADR 0277 §9), under the router's // bound for an approval. -const ProposalLasts = asks.ApproveLasts - 10*time.Minute +const ProposalLasts = occ.ApproveLasts - 10*time.Minute // How long a proposal waits for the router's word on its new ask, and how often it looks (ADR 0277 §20). const ( @@ -182,8 +183,8 @@ func (p Proposal) about() string { // Act is what an answer binds: this proposal's level, key, the digest of its exact value and of the value it was // shown against, who proposed it and when, and the answer. -func (p Proposal) Act(option string) asks.Act { - act := asks.Act{"verb": ProposalVerb, "option": option, "proposal": p.ID, "action": p.Action, +func (p Proposal) Act(option string) occ.Act { + act := occ.Act{"verb": ProposalVerb, "option": option, "proposal": p.ID, "action": p.Action, "level": p.Level.Kind, "node": p.Level.Node, "module": p.Level.Module, "key": p.Key, "value": p.Digest, "was": p.WasDigest, "had-value": strconv.FormatBool(p.HadValue), "from": p.From, "at": p.At.UTC().Format(time.RFC3339Nano)} @@ -273,7 +274,7 @@ func (p Proposal) summary() string { s := fmt.Sprintf("Holds %s for %s on %s for %s; approved, it takes effect when that pull request merges, "+ "and is dropped if it closes unmerged. Nothing reaches a machine before the merge.", p.Key, p.Level.Module, p.Level.Node, p.Pull) - if len([]rune(s)) > asks.SummaryLength { + if len([]rune(s)) > occ.SummaryLength { s = fmt.Sprintf("Holds %s for %s on %s for %s until it merges; dropped if it closes unmerged.", p.Key, p.Level.Module, p.Level.Node, p.Pull) } @@ -282,7 +283,7 @@ func (p Proposal) summary() string { reach := p.reaches() s := fmt.Sprintf("%s%s at %s; approved, it takes effect at once on every node it reaches: %s.", verb, trusted, p.Level, reach) - if len([]rune(s)) > asks.SummaryLength { + if len([]rune(s)) > occ.SummaryLength { s = fmt.Sprintf("%s%s at %s; approved, it takes effect at once on every node it reaches (%d modules).", verb, trusted, p.Level, len(p.Reaches)) } @@ -292,8 +293,8 @@ func (p Proposal) summary() string { // point is a line cut to what a phone shows at a glance, saying so. func point(line string) string { const cut = "… (cut: read it in the note)" - if r := []rune(line); len(r) > asks.PointLength { - return string(r[:asks.PointLength-len([]rune(cut))]) + cut + if r := []rune(line); len(r) > occ.PointLength { + return string(r[:occ.PointLength-len([]rune(cut))]) + cut } return line } @@ -316,7 +317,7 @@ func (p Proposal) headline() string { } for _, h := range []string{fmt.Sprintf("%s %s %s?", verb, p.Key, where), fmt.Sprintf("%s a config value %s?", verb, where), verb + " a config value?"} { - if len([]rune(h)) <= asks.HeadlineLength { + if len([]rune(h)) <= occ.HeadlineLength { if s, ok := sayable(h); ok { return s } @@ -327,7 +328,7 @@ func (p Proposal) headline() string { // Ask is the proposal's ask, composed from it alone: its words, its two options each binding its act, who // answers, its expiry and what it is about. -func (p Proposal) Ask() asks.Ask { +func (p Proposal) Ask() oc.Ask { why, _ := sayable("Why: " + p.Why) if why != "Why: "+p.Why { why = "Why: given, and read in full with the proposal" @@ -348,8 +349,8 @@ func (p Proposal) Ask() asks.Ask { "proposal %s, through the mesh MCP server.", p.ID) return d.String() } - a := asks.Ask{ID: p.ID, Headline: p.headline(), Explanation: compose(false), Who: asks.Operator, - Expires: p.At.Add(ProposalLasts), OnExpiry: "the proposal is discarded; nothing changes", About: p.about(), + a := oc.Ask{ID: p.ID, Headline: p.headline(), Explanation: compose(false), Who: occ.Operator, + Expires: occ.FormatTime(p.At.Add(ProposalLasts)), OnExpiry: "the proposal is discarded; nothing changes", About: p.about(), Details: details(false)} if w := compose(true); w != a.Explanation { a.Whole = w @@ -368,8 +369,8 @@ func (p Proposal) Ask() asks.Ask { {OptionApprove, "Approve", does}, {OptionDecline, "Decline", "nothing changes; the proposal is discarded"}, } { - binds, _ := asks.ActDigest(p.Act(o.id)) - a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, + binds, _ := occ.ActDigest(p.Act(o.id)) + a.Options = append(a.Options, oc.Option{ID: o.id, Label: o.label, Does: o.does, Level: occ.Approve, Binds: binds}) } return a @@ -379,7 +380,7 @@ func (p Proposal) Ask() asks.Ask { // acted on, so a record whose value or shown value changed after it was asked is refused (ADR 0277 §12). func (p Proposal) problem() string { switch { - case !asks.UsableID(p.ID): + case !occ.UsableID(p.ID): return "its id is not one" case p.Action != "set" && p.Action != "clear": return "its action is neither set nor clear" @@ -426,7 +427,7 @@ func approverReady(bus AskBus) string { } channels := 0 for _, k := range kinds { - if k.Seat != asks.ChannelSeat { + if k.Seat != occ.ChannelSeat { continue } channels++ @@ -446,9 +447,15 @@ func newProposalID() string { return "cfg-" + hex.EncodeToString(b[:]) } +// warrantAt is the time a warrant carries; the zero time when it carries none. +func warrantAt(w oc.Warrant) time.Time { + t, _ := occ.Time(w.At) + return t +} + // endedWord is the router's word that an ask ended without a choice, heard while its proposer waits. type endedWord struct { - Outcome asks.Outcome + Outcome string Words string } @@ -496,8 +503,9 @@ func (c *Config) Propose(call Call) (*ProposeAnswer, error) { if err != nil { return nil, err } + askExpires, _ := occ.Time(ask.Expires) answer := &ProposeAnswer{Proposal: prop.ID, Asked: ask.Headline, Fingerprint: prop.Fingerprint(), - Expires: ask.Expires, Reaches: prop.Reaches, Replaced: replaced, + Expires: askExpires, Reaches: prop.Reaches, Replaced: replaced, Router: "the router has not said yet whether it took the ask; config's proposals shows it"} // The router's word, before answering (ADR 0277 §20): it refuses at once an ask no channel can carry. within, every := c.RouterWithin, c.RouterEvery @@ -520,14 +528,14 @@ func (c *Config) Propose(call Call) (*ProposeAnswer, error) { answer.Said = fmt.Sprintf("proposed %s of %s at %s (fingerprint %s): the operator is asked %q on a channel that "+ "proves who answered. Nothing changes until they approve; on Approve it takes effect at once, and on Decline "+ "or at %s UTC it is discarded", action, prop.Key, prop.Level, prop.Fingerprint(), ask.Headline, - ask.Expires.UTC().Format("15:04 on 2 Jan")) + askExpires.UTC().Format("15:04 on 2 Jan")) return answer, nil } // propose is Propose's judgement, its record and its ask; the caller holds the lock. -func (c *Config) propose(call Call, action string) (Proposal, asks.Ask, []string, error) { - fail := func(format string, a ...any) (Proposal, asks.Ask, []string, error) { - return Proposal{}, asks.Ask{}, nil, errors.New(fmt.Sprintf(format, a...) + ". Nothing was proposed") +func (c *Config) propose(call Call, action string) (Proposal, oc.Ask, []string, error) { + fail := func(format string, a ...any) (Proposal, oc.Ask, []string, error) { + return Proposal{}, oc.Ask{}, nil, errors.New(fmt.Sprintf(format, a...) + ". Nothing was proposed") } level, err := call.level() if err != nil { @@ -546,7 +554,7 @@ func (c *Config) propose(call Call, action string) (Proposal, asks.Ask, []string } s, err := c.sources() if err != nil { - return Proposal{}, asks.Ask{}, nil, err + return Proposal{}, oc.Ask{}, nil, err } // **Judged as a set judges it, before anyone is asked** (ADR 0277 §4): no ask for a value the verb would refuse. var r rule @@ -572,7 +580,7 @@ func (c *Config) propose(call Call, action string) (Proposal, asks.Ask, []string } trusted = false k, kept := s.Kept[keptKey(call.Pull, level.Node, level.Module, call.Key)] - was, had = Value{Value: k.Value}, kept + was, had = Value{Value: heldValue(k)}, kept } else { var derr error r, trusted, derr = c.declared(s, level, call.Key) @@ -637,7 +645,7 @@ func (c *Config) propose(call Call, action string) (Proposal, asks.Ask, []string // replaces an older one still open (§10). all, err := c.proposals() if err != nil { - return Proposal{}, asks.Ask{}, nil, err + return Proposal{}, oc.Ask{}, nil, err } var replace []Proposal open := 0 @@ -654,15 +662,15 @@ func (c *Config) propose(call Call, action string) (Proposal, asks.Ask, []string } open++ } - if open >= asks.MostOpen { + if open >= occ.MostOpen { return fail("%d proposals already wait for the operator's answer, and mesh-config asks at most %d at once; "+ - "config's proposals lists them", open, asks.MostOpen) + "config's proposals lists them", open, occ.MostOpen) } if why := approverReady(c.Asks); why != "" { return fail("%s; %s", why, AtTheTerminalInstead) } ask := prop.Ask() - if err := ask.Check(now); err != nil { + if err := occ.Check(ask, now); err != nil { return fail("%v", err) } if err := c.Store.Put(ProposalsState, prop.ID, prop); err != nil { @@ -675,11 +683,11 @@ func (c *Config) propose(call Call, action string) (Proposal, asks.Ask, []string replaced = append(replaced, o.ID) } } - if err := c.Asks.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil { + if err := c.Asks.Publish(oc.AcceptSubjectAsk(Module), ask, ask.ID); err != nil { prop.State, prop.Outcome = ProposalUnasked, "the ask could not be published: "+err.Error() _ = c.Store.Put(ProposalsState, prop.ID, prop) c.logf("[mesh-config] proposal %s could not be asked: %v", prop.ID, err) - return Proposal{}, asks.Ask{}, nil, fmt.Errorf("the operator could not be asked (%v); the proposal is kept "+ + return Proposal{}, oc.Ask{}, nil, fmt.Errorf("the operator could not be asked (%v); the proposal is kept "+ "and will never be set; %s", err, AtTheTerminalInstead) } c.logf("[mesh-config] proposal %s (%s %s at %s, fingerprint %s) asked of the operator", prop.ID, action, prop.Key, @@ -709,9 +717,9 @@ func (c *Config) proposals() ([]Proposal, error) { // routerRecord is the router's record of one ask, as far as this module reads it. type routerRecord struct { - State string `json:"state"` - Ask *asks.Ask `json:"ask"` - Warrant *asks.Warrant `json:"warrant"` + State string `json:"state"` + Ask *oc.Ask `json:"ask"` + Warrant *oc.Warrant `json:"warrant"` } // Decide decides every waiting proposal whose ask the router has closed, oldest warrant first, each held to the @@ -730,7 +738,7 @@ func (c *Config) Decide() ([]string, error) { now := c.now() type closed struct { p Proposal - w asks.Warrant + w oc.Warrant } var ready []closed var said []string @@ -754,11 +762,13 @@ func (c *Config) Decide() ([]string, error) { } ready = append(ready, closed{p, *rec.Warrant}) } - sort.SliceStable(ready, func(i, j int) bool { return ready[i].w.At.Before(ready[j].w.At) }) + sort.SliceStable(ready, func(i, j int) bool { return warrantAt(ready[i].w).Before(warrantAt(ready[j].w)) }) for _, r := range ready { said = append(said, c.decide(r.p, r.w)) } c.recordWarrants(now) + // The retirements' asks are taken from the router's record the same way (retiring.go). + said = append(said, c.decideRetirements()...) return said, nil } @@ -773,7 +783,7 @@ func (c *Config) end(p Proposal, why string) string { // decide holds one closed ask's warrant to its proposal and, on an Approve that holds, sets the value. The caller // holds the lock. -func (c *Config) decide(p Proposal, w asks.Warrant) string { +func (c *Config) decide(p Proposal, w oc.Warrant) string { now := c.now() keep := func(state, outcome string) string { p.State, p.Outcome, p.Decided = state, outcome, now @@ -789,7 +799,7 @@ func (c *Config) decide(p Proposal, w asks.Warrant) string { if w.Asker != Module || w.Ask != p.ID { return refused(fmt.Sprintf("the router's record is for %s's ask %s", w.Asker, w.Ask)) } - if w.Outcome != asks.OutcomeChosen { + if w.Outcome != occ.OutcomeChosen { why := "the ask " + string(w.Outcome) if w.Words != "" { why += " (" + w.Words + ")" @@ -802,19 +812,19 @@ func (c *Config) decide(p Proposal, w asks.Warrant) string { if problem := p.problem(); problem != "" { return refused("the proposal is not the one asked: " + problem) } - option, err := w.For(Module, p.Ask()) + option, err := occ.For(w, Module, p.Ask()) if err == nil { - err = option.Performs(p.Act(option.ID)) + err = occ.Performs(option, p.Act(option.ID)) } if err != nil { return refused(err.Error()) } switch option.ID { case OptionDecline: - p.Answered = w.Says() - return keep(ProposalDeclined, "declined by the operator ("+w.Says()+"); nothing was set") + p.Answered = occ.Says(w) + return keep(ProposalDeclined, "declined by the operator ("+occ.Says(w)+"); nothing was set") case OptionApprove: - p.Answered, p.Approved = w.Says(), approvedWords(p, w) + p.Answered, p.Approved = occ.Says(w), approvedWords(p, w) default: return refused("the option " + option.ID + " is not one a proposal offers") } @@ -851,7 +861,7 @@ func (c *Config) decide(p Proposal, w asks.Warrant) string { } // approvedWords is who approved it, as kept beside the value (ADR 0277 §15). -func approvedWords(p Proposal, w asks.Warrant) string { +func approvedWords(p Proposal, w oc.Warrant) string { who := "the operator" if w.By != nil { who = "the " + w.By.Who @@ -864,7 +874,7 @@ func approvedWords(p Proposal, w asks.Warrant) string { } who += ", via " + via } - return fmt.Sprintf("approved by %s at %s, ask %s, proposed by %s", who, w.At.UTC().Format(time.RFC3339), p.ID, p.From) + return fmt.Sprintf("approved by %s at %s, ask %s, proposed by %s", who, warrantAt(w).UTC().Format(time.RFC3339), p.ID, p.From) } // recordWarrants asks the controller to record each warrant acted on (Approve or Decline) that its hand-act log @@ -890,11 +900,11 @@ func (c *Config) recordWarrants(now time.Time) { // HeardEnd takes the router's word on one of this module's asks. An ask ended without a choice ends its proposal // at once, and is told to a proposer waiting on it; nothing is set on such a word, so it needs no proof beyond its // subject. A choice is only ever taken from the router's record (Decide). -func (c *Config) HeardEnd(w asks.Warrant) []string { - if w.Asker != Module || !asks.UsableID(w.Ask) { +func (c *Config) HeardEnd(w oc.Warrant) []string { + if w.Asker != Module || !occ.UsableID(w.Ask) { return nil } - if w.Outcome == asks.OutcomeChosen { + if w.Outcome == occ.OutcomeChosen { said, err := c.Decide() if err != nil { said = append(said, "reading the operator's answers: "+err.Error()) @@ -982,7 +992,7 @@ func (c *Config) Proposals(call Call) ([]ProposalRow, error) { switch { case rec.Ask == nil: row.Phone = "cannot be compared: the router's record keeps no ask" - case rec.Ask.Digest() == p.Ask().Digest(): + case occ.Digest(*rec.Ask) == occ.Digest(p.Ask()): row.Phone = "shows the ask composed from exactly this proposal (fingerprint " + p.Fingerprint() + ")" default: row.Phone = "shows ANOTHER ask than this proposal composes: do not approve it" diff --git a/modules/mesh-config/cmd/mesh-config/proposals_test.go b/modules/mesh-config/cmd/mesh-config/proposals_test.go index 65c668bbf..e41c281d3 100644 --- a/modules/mesh-config/cmd/mesh-config/proposals_test.go +++ b/modules/mesh-config/cmd/mesh-config/proposals_test.go @@ -10,8 +10,9 @@ import ( "testing" "time" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/asks" ) // standIn is the operator channel's router and a channel that proves who answered, in memory: it takes the asks @@ -20,7 +21,7 @@ import ( type standIn struct { mu sync.Mutex subjects []string - asks map[string]asks.Ask + asks map[string]oc.Ask records map[string][]byte kinds []stdio.KindHeld refuse error @@ -28,14 +29,14 @@ type standIn struct { // refuseWith makes the router refuse every ask it is published, as the router does for an ask no channel can // carry: no record, and its word on the decided subject (heard through hear). refuseWith string - hear func(asks.Warrant) + hear func(oc.Warrant) // noRecord keeps the router from recording an ask it took (an ask it has not taken yet). noRecord bool } func newStandIn() *standIn { - return &standIn{asks: map[string]asks.Ask{}, records: map[string][]byte{}, kinds: []stdio.KindHeld{ - {Seat: asks.ChannelSeat, Kind: "telegram", Module: "telegram", Node: "novox", + return &standIn{asks: map[string]oc.Ask{}, records: map[string][]byte{}, kinds: []stdio.KindHeld{ + {Seat: occ.ChannelSeat, Kind: "telegram", Module: "telegram", Node: "novox", Capabilities: []string{"choice", "exact-render", "verified-sender"}}}} } @@ -45,15 +46,15 @@ func (r *standIn) Publish(subject string, body any, id string) error { if r.refuse != nil { return r.refuse } - a, ok := body.(asks.Ask) + a, ok := body.(oc.Ask) if !ok || a.ID != id { return errors.New("not an ask") } r.subjects = append(r.subjects, subject) r.asks[id] = a if r.refuseWith != "" { - w := asks.Warrant{Ask: id, Asker: Module, About: a.About, Outcome: asks.OutcomeRefused, Words: r.refuseWith, - At: time.Now()} + w := oc.Warrant{Ask: id, Asker: Module, About: a.About, Outcome: occ.OutcomeRefused, Words: r.refuseWith, + At: occ.FormatTime(time.Now())} go r.hear(w) return nil } @@ -85,7 +86,7 @@ func (r *standIn) Warranted(ask string) error { // choose is the operator choosing an option on a channel that proves who answered: the router's record of the ask // it took, closed with its warrant. -func (r *standIn) choose(t *testing.T, id, option string, tweak func(*asks.Warrant)) asks.Warrant { +func (r *standIn) choose(t *testing.T, id, option string, tweak func(*oc.Warrant)) oc.Warrant { t.Helper() r.mu.Lock() defer r.mu.Unlock() @@ -93,14 +94,14 @@ func (r *standIn) choose(t *testing.T, id, option string, tweak func(*asks.Warra if !ok { t.Fatalf("the router took no ask %s", id) } - o, ok := a.Option(option) + o, ok := occ.OptionOf(a, option) if !ok { t.Fatalf("the ask %s offers no %s", id, option) } - w := asks.Warrant{Ask: id, Asker: Module, About: a.About, Outcome: asks.OutcomeChosen, Option: o.ID, Label: o.Label, - Level: o.Level, By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "4242", Verified: "user id verified"}, - Channel: "telegram", Proofs: []string{"P1"}, At: time.Date(2026, 10, 14, 19, 30, 0, 0, time.UTC), - AskDigest: a.Digest()} + w := oc.Warrant{Ask: id, Asker: Module, About: a.About, Outcome: occ.OutcomeChosen, Option: o.ID, Label: o.Label, + Level: o.Level, By: &oc.Person{Who: occ.Operator, Kind: "telegram", Identity: "4242", Verified: "user id verified"}, + Channel: "telegram", Proofs: []string{"P1"}, At: occ.FormatTime(time.Date(2026, 10, 14, 19, 30, 0, 0, time.UTC)), + AskDigest: occ.Digest(a)} if tweak != nil { tweak(&w) } @@ -114,7 +115,7 @@ func aProposingMesh(t *testing.T) (*Config, *memStore, *standIn) { t.Helper() c, m := aMesh(t) r := newStandIn() - r.hear = func(w asks.Warrant) { c.HeardEnd(w) } + r.hear = func(w oc.Warrant) { c.HeardEnd(w) } c.Asks, c.RouterWithin, c.RouterEvery = r, 300*time.Millisecond, 5*time.Millisecond return c, m, r } @@ -180,14 +181,14 @@ func TestATrustedValueIsProposedAskedUnderItsOwnNameAndSetOnTheOperatorsApprove( t.Errorf("the proposal answered %+v", a) } // Asked under mesh-config's own name, on the operator channel. - if len(r.subjects) != 1 || r.subjects[0] != asks.AskSubject("mesh-config") { + if len(r.subjects) != 1 || r.subjects[0] != oc.AcceptSubjectAsk("mesh-config") { t.Fatalf("asked on %v", r.subjects) } ask := r.asks[a.Proposal] - if err := ask.Check(c.now()); err != nil { + if err := occ.Check(ask, c.now()); err != nil { t.Fatalf("the ask: %v", err) } - if err := asks.Glance(ask.Explanation); err != nil { + if err := occ.Glance(ask.Explanation); err != nil { t.Errorf("the ask does not read at a glance: %v\n%s", err, ask.Explanation) } for _, want := range []string{"agent-managed-settings", "mesh config", "takes effect at once", "claude-code", @@ -211,11 +212,11 @@ func TestATrustedValueIsProposedAskedUnderItsOwnNameAndSetOnTheOperatorsApprove( // Each option binds the level, the key and the value. p := storedProposal(t, m, a.Proposal) for _, o := range ask.Options { - if o.Level != asks.Approve { + if o.Level != occ.Approve { t.Errorf("the option %s is at %s", o.ID, o.Level) } act := p.Act(o.ID) - want, _ := asks.ActDigest(act) + want, _ := occ.ActDigest(act) if o.Binds != want || act["level"] != LevelMesh || act["key"] != "agent-managed-settings" || act["value"] != valueDigest(json.RawMessage(`{"permissions":{"deny":["Bash(ssh:*)"]}}`), true) || act["was"] != valueDigest(nil, false) { @@ -338,13 +339,13 @@ func TestAnApproveIsRefusedWhenTheProposalChangedAfterItWasAsked(t *testing.T) { func TestAWarrantThatDoesNotAnswerTheAskIsRefused(t *testing.T) { for _, tc := range []struct { name string - tweak func(w *asks.Warrant) + tweak func(w *oc.Warrant) }{ - {"another ask's digest", func(w *asks.Warrant) { w.AskDigest = "sha256:00" }}, - {"another level", func(w *asks.Warrant) { w.Level = asks.Acknowledge }}, - {"after its expiry", func(w *asks.Warrant) { w.At = time.Date(2026, 10, 16, 0, 0, 0, 0, time.UTC) }}, - {"an option it did not offer", func(w *asks.Warrant) { w.Option = "force" }}, - {"no person", func(w *asks.Warrant) { w.By = nil }}, + {"another ask's digest", func(w *oc.Warrant) { w.AskDigest = "sha256:00" }}, + {"another level", func(w *oc.Warrant) { w.Level = occ.Acknowledge }}, + {"after its expiry", func(w *oc.Warrant) { w.At = occ.FormatTime(time.Date(2026, 10, 16, 0, 0, 0, 0, time.UTC)) }}, + {"an option it did not offer", func(w *oc.Warrant) { w.Option = "force" }}, + {"no person", func(w *oc.Warrant) { w.By = nil }}, } { t.Run(tc.name, func(t *testing.T) { c, m, r := aProposingMesh(t) @@ -379,7 +380,7 @@ func TestDeclineExpiryAndTheRoutersRefusalSetNothing(t *testing.T) { // Ended without a choice: the router's word. a = propose(t, c, Call{Key: "font-size", Value: 12.0, Node: "g14"}) - c.HeardEnd(asks.Warrant{Ask: a.Proposal, Asker: Module, Outcome: asks.OutcomeExpired, At: time.Now()}) + c.HeardEnd(oc.Warrant{Ask: a.Proposal, Asker: Module, Outcome: occ.OutcomeExpired, At: occ.FormatTime(time.Now())}) if p := storedProposal(t, m, a.Proposal); p.State != ProposalEnded || !strings.Contains(p.Outcome, "expired") { t.Errorf("the expired proposal is %+v", p) } @@ -457,7 +458,7 @@ func TestAProposalIsJudgedAndRefusedBeforeTheOperatorIsAsked(t *testing.T) { t.Fatalf("a refused proposal was asked: %v", r.subjects) } - r.kinds = []stdio.KindHeld{{Seat: asks.ChannelSeat, Kind: "desktop", Capabilities: []string{"choice"}}} + r.kinds = []stdio.KindHeld{{Seat: occ.ChannelSeat, Kind: "desktop", Capabilities: []string{"choice"}}} if _, err := c.Propose(Call{Action: "propose", Key: "font-size", Value: 13.0, Caller: "g", Why: "w"}); err == nil || !strings.Contains(err.Error(), "no channel that proves who answered") { t.Errorf("no approving channel: %v", err) diff --git a/modules/mesh-config/cmd/mesh-config/pulls.go b/modules/mesh-config/cmd/mesh-config/pulls.go index 22849bd89..0051d8781 100644 --- a/modules/mesh-config/cmd/mesh-config/pulls.go +++ b/modules/mesh-config/cmd/mesh-config/pulls.go @@ -6,12 +6,11 @@ import ( "encoding/json" "errors" "fmt" + "git.novox.be/novox/mesh-contracts/go/clients/meshconfigclient" "regexp" "strconv" "strings" "time" - - "git.novox.be/novox/mesh-sdk/go/delivery" ) // A config value held for the open pull request that introduces its key (novox/hq ADR 0502, issue 1826). @@ -63,19 +62,25 @@ type PullDecl struct { At time.Time `json:"at"` } -// KeptValue is an approved held value, kept apart from the values mesh-config resolves: mesh-sdk's layout, which the -// checker and the assigner's composition check read (novox/hq ADR 0565 §5). -type KeptValue = delivery.KeptValue +// KeptValue is an approved held value, kept apart from the values mesh-config resolves, in the layout of the seat's state +// `kept`, which the checker and the assigner's composition check read (novox/hq ADR 0565 §5, ADR 0634 §4). +type KeptValue = meshconfigclient.Kept -// keptKey is a held value's key in `kept` (delivery.HeldValueKey), the pull request's key form kept for one that is -// not one. +// keptKey is a held value's key in `kept`: the pull request's key form (pullKey), the node, the module and the config +// key, joined by dots. func keptKey(pull, node, module, key string) string { - if k, err := delivery.HeldValueKey(pull, node, module, key); err == nil { - return k - } - return pullKey(pull) + "." + node + "." + module + "." + key + k, _ := meshconfigclient.HeldValueKey(pull, node, module, key) // a held value of no pull request has no key, and is found by none + return k } +// heldValue is the value held, as the operator approved it, in its JSON form. +func heldValue(k KeptValue) json.RawMessage { + raw, _ := meshconfigclient.ValueOf(k) // a value that was read from JSON writes as JSON + return raw +} + +func heldAt(t time.Time) *time.Time { return &t } + func keptStateKey(k KeptValue) string { return keptKey(k.Pull, k.Node, k.Module, k.Key) } func keptLevel(k KeptValue) Level { return Level{Kind: LevelModule, Node: k.Node, Module: k.Module} } @@ -112,9 +117,9 @@ func (c *Config) readPulls(s *Sources) error { return fmt.Errorf("the pull requests' declarations cannot be read: %w", err) } if err := c.each(KeptState, func(key string, raw []byte) error { - var k KeptValue - if err := json.Unmarshal(raw, &k); err != nil || k.Pull == "" { - return nil + k, err := meshconfigclient.DecodeKept(key, raw) + if err != nil { + return nil // not a held value of the seat's layout under its own key } s.Kept[key] = k return nil @@ -165,11 +170,11 @@ func (c *Config) declarePull(pull, head string, declares map[string]map[string]D switch { case !declared: why = "the head no longer adds or changes its declaration" - case declDigest(d) != kv.Digest: + case declDigest(d) != kv.Declaration: why = "the head declares the key otherwise than the value was approved against" default: var v any - _ = json.Unmarshal(kv.Value, &v) + _ = json.Unmarshal(heldValue(kv), &v) if p := d.rule().check(v); len(p) > 0 { why = "the value no longer passes: " + strings.Join(p, "; ") } @@ -197,13 +202,13 @@ func (c *Config) declarePull(pull, head string, declares map[string]map[string]D // config-changed; what could not be done is answered, not dropped. func (c *Config) heldChange(kv KeptValue, action, why, approved string) []string { level := keptLevel(kv) - ch := Change{Action: action, Level: &level, Key: kv.Key, Value: kv.Value, By: kv.By, Via: kv.Via, Why: why, + ch := Change{Action: action, Level: &level, Key: kv.Key, Value: heldValue(kv), By: kv.By, Via: kv.Via, Why: why, Approved: approved, Pull: kv.Pull, Head: kv.Head, At: c.now(), Moved: []Moved{}} unsaid := c.record(level.ValueKey(kv.Key), ch) cause := "a value held for " + kv.Pull what := fmt.Sprintf("config %s %s of %s for %s, held for %s", action, kv.Key, kv.Module, kv.Node, kv.Pull) if action != ActionDrop { - what += " (" + string(kv.Value) + ")" + what += " (" + string(heldValue(kv)) + ")" } if err := c.Store.HandAct(what, why, cause); err != nil { unsaid = append(unsaid, "the hand-act log does not hold it: "+err.Error()) @@ -249,8 +254,8 @@ type HeldSkipped struct { } func heldValueOf(k KeptValue) HeldValue { - return HeldValue{Pull: k.Pull, Head: k.Head, Node: k.Node, Module: k.Module, Key: k.Key, Value: compact(k.Value), - Approved: k.Approved, By: k.By, Why: k.Why, At: k.At} + return HeldValue{Pull: k.Pull, Head: k.Head, Node: k.Node, Module: k.Module, Key: k.Key, Value: compact(heldValue(k)), + Approved: k.Approved, By: k.By, Why: k.Why, At: timeOf(k.At)} } // Held answers the approved held values for the pull requests asked, at the heads asked, to the controller alone: a @@ -362,13 +367,13 @@ func (c *Config) keep(p Proposal) (string, error) { "propose it again", p.Pull, p.Key) } was, had := s.Kept[keptKey(p.Pull, p.Level.Node, p.Level.Module, p.Key)] - if had != p.HadValue || valueDigest(was.Value, had) != p.WasDigest { + if had != p.HadValue || valueDigest(heldValue(was), had) != p.WasDigest { return "", fmt.Errorf("the value held for %s changed since the operator was shown the change: propose it "+ "again", p.Pull) } - kv := KeptValue{Pull: p.Pull, Head: pd.Head, Digest: p.DeclDigest, Node: p.Level.Node, Module: p.Level.Module, + kv := KeptValue{Pull: p.Pull, Head: pd.Head, Declaration: p.DeclDigest, Node: p.Level.Node, Module: p.Level.Module, Key: p.Key, Value: p.Value, By: "the operator", Via: p.From, Why: p.Why, Approved: p.Approved, Proposal: p.ID, - At: c.now()} + At: heldAt(c.now())} if err := c.Store.Put(KeptState, keptStateKey(kv), kv); err != nil { return "", fmt.Errorf("the value was not kept: %v", err) } @@ -423,7 +428,7 @@ func (c *Config) settlePull(pull string, merged bool, head string) []string { why = "the merged head is not known, so the value cannot be held to it" case !strings.EqualFold(head, kv.Head): why = fmt.Sprintf("it is held at head %s and the pull request merged at %s", short(kv.Head), short(head)) - case !known || !declared || declDigest(d) != kv.Digest: + case !known || !declared || declDigest(d) != kv.Declaration: why = "the merged head does not declare the key as the operator was shown" } if why != "" { @@ -433,7 +438,7 @@ func (c *Config) settlePull(pull string, merged bool, head string) []string { continue } vk := keptLevel(kv).ValueKey(kv.Key) - v := Value{Value: compact(kv.Value), By: kv.By, Via: kv.Via, Why: kv.Why + " (held for " + kv.Pull + + v := Value{Value: compact(heldValue(kv)), By: kv.By, Via: kv.Via, Why: kv.Why + " (held for " + kv.Pull + ", merged)", Approved: kv.Approved, At: c.now()} if err := c.Store.Put(ValuesState, vk, v); err != nil { said = append(said, fmt.Sprintf("%s of %s on %s held for %s is not written yet: %v; it is tried "+ diff --git a/modules/mesh-config/cmd/mesh-config/pulls_test.go b/modules/mesh-config/cmd/mesh-config/pulls_test.go index 341b0bfb1..45b16a5a4 100644 --- a/modules/mesh-config/cmd/mesh-config/pulls_test.go +++ b/modules/mesh-config/cmd/mesh-config/pulls_test.go @@ -3,9 +3,12 @@ package main import ( "encoding/json" "errors" + "git.novox.be/novox/mesh-contracts/go/clients/meshconfigclient" "strings" "testing" "time" + + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" ) // Issue 1826 (novox/hq ADR 0502): a value held for the open pull request that introduces its key. @@ -174,7 +177,7 @@ func TestAHeldValueIsAskedAndKeptApartOnApprove(t *testing.T) { t.Errorf("a held value reaches %v", a.Reaches) } ask := r.asks[a.Proposal] - if err := ask.Check(c.now()); err != nil { + if err := occ.Check(ask, c.now()); err != nil { t.Fatalf("the ask: %v", err) } for _, want := range []string{thePull, "merges", "dropped if it closes unmerged", "media-gid", "n8n", "ace"} { @@ -315,8 +318,11 @@ func TestTheMergeAppliesAHeldValueAndItResolvesOnceTheTrunkDeclaresTheKey(t *tes holdIt(t, c, m, r) approved := func() string { raw, _ := m.Get(KeptState, "novox.mesh-catalog.434.ace.n8n.media-gid") - var k KeptValue - _ = json.Unmarshal(raw.Value, &k) + // the state kept is the seat's layout, under the seat's key: the checker and the assigner read it with this + k, err := meshconfigclient.DecodeKept("novox.mesh-catalog.434.ace.n8n.media-gid", raw.Value) + if err != nil || string(heldValue(k)) != "2000" || k.Declaration == "" || k.At == nil { + t.Fatalf("the held value is not the seat's: %+v, %v", k, err) + } return k.Approved }() m.events, m.handActs = nil, nil diff --git a/modules/mesh-config/cmd/mesh-config/readers.go b/modules/mesh-config/cmd/mesh-config/readers.go new file mode 100644 index 000000000..e5510a65f --- /dev/null +++ b/modules/mesh-config/cmd/mesh-config/readers.go @@ -0,0 +1,23 @@ +package main + +import ( + "fmt" + + meshwants "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" +) + +// The holders whose states this module reads, one constant each (a reader names its holder once; ADR 0634 §4). +const ( + assignerModule = "mesh-delivery-assigner" + indexerModule = "mesh-delivery-indexer" +) + +// parseWantKey reads a want's key, `want..`; the want of a node's own and a held fingerprint's key are +// not one. +func parseWantKey(key string) (node, module string, err error) { + node, module, ok := meshwants.ParseWantsByWantNodeModuleKey(key) + if !ok { + return "", "", fmt.Errorf("%q is not the key of a module's want on a node", key) + } + return node, module, nil +} diff --git a/modules/mesh-config/cmd/mesh-config/renames.go b/modules/mesh-config/cmd/mesh-config/renames.go new file mode 100644 index 000000000..a22de1506 --- /dev/null +++ b/modules/mesh-config/cmd/mesh-config/renames.go @@ -0,0 +1,494 @@ +package main + +// A config value renamed (novox/hq ADR 0609 §4, design 32 *A config value renamed*). +// +// A declaration naming `renamed-from` moves the value set under the old name to the new name, with the value's trust +// (its Value, whole), and opens a **rename window** per node: until that node's node-engine has applied a build of the +// module that reads the new name, the old name still answers with the new value (Resolve). A window closes on the +// status view's `delivered` event and, for a node that was away, on the node-engine seat's `manifest-reads-changed` +// (Windows). Its state is `renames`, key `.`; what it says is `renamed` on the stream `config-events`. + +import ( + "encoding/json" + "fmt" + "slices" + "sort" + "strings" + "time" +) + +// The state, the stream's events and the bounds this part keeps. +const ( + RenamesState = "renames" + // EventRenamed is `renamed`, EventRetiring `retiring` and EventRetired `retired`, the family `config.events` of the + // seat's definition (design 26), under the holder's subjects: each carried by the stream StreamName, which keeps its history. + EventRenamed = "renamed" + EventRetiring = "retiring" + EventRetired = "retired" + StreamName = "config-events" + // HeldFinding is how long a window or a retirement may stay open before it is a finding naming the node. + HeldFinding = 7 * 24 * time.Hour +) + +// Rename is one rename, as `renames` keeps it, key `.`. +type Rename struct { + Module string `json:"module"` + From string `json:"from"` + To string `json:"to"` + At time.Time `json:"at"` + // Moved are the nodes a value was moved on, by the last move of each. + Moved []string `json:"moved,omitempty"` + // Nodes are the windows, by node. + Nodes map[string]Window `json:"nodes"` + // Said says `renamed` was emitted; false until the bus took it. + Said bool `json:"said"` +} + +// Window is one node's rename window: open until Closed is set. +type Window struct { + Since time.Time `json:"since"` + Closed *time.Time `json:"closed,omitempty"` + // How is what closed it: "applied" (the node runs a build reading the new name), "unplaced" (the module left the + // node), or "by hand" (config_rename_close). + How string `json:"how,omitempty"` +} + +// Open is the nodes whose window is open, sorted. +func (r Rename) Open() []string { + var out []string + for node, w := range r.Nodes { + if w.Closed == nil { + out = append(out, node) + } + } + sort.Strings(out) + return out +} + +// renameKey is a rename's key in `renames`. +func renameKey(module, to string) string { return module + "." + to } + +// RenamedEvent is the body of `renamed`. +type RenamedEvent struct { + Module string `json:"module"` + From string `json:"from"` + To string `json:"to"` + At time.Time `json:"at"` +} + +// readRenames is every rename kept, by key. +func (c *Config) readRenames() (map[string]Rename, error) { + out := map[string]Rename{} + err := c.each(RenamesState, func(key string, raw []byte) error { + var r Rename + if json.Unmarshal(raw, &r) != nil || r.Module == "" || renameKey(r.Module, r.To) != key { + return nil // not one this module wrote + } + if r.Nodes == nil { + r.Nodes = map[string]Window{} + } + out[key] = r + return nil + }) + return out, err +} + +// moduleValueKey is the key of a module's own value on a node. +func moduleValueKey(node, module, key string) string { + return Level{Kind: LevelModule, Node: node, Module: module}.ValueKey(key) +} + +// applyRenames moves the values of every rename the trunk's manifests declare, opens the windows of the nodes the +// module is placed on, and writes `renamed` once: run on each resolution, under the lock, before the resolved values +// are written. s.Values and s.Renames are brought to what it did, so the same resolution sees it. A rename it refuses +// is a finding (findings.go), cleared once it moved or `renamed-from` left the manifest. It answers a line for what it +// did or could not do. +func (c *Config) applyRenames(s *Sources) []string { + held, err := c.readRenames() + if err != nil { + return []string{"the renames cannot be read: " + err.Error()} + } + now := c.now() + var said []string + seen := map[string]bool{} + for _, module := range modulesOf(s.Assignments) { + trunk, err := c.trunkDeclarations(module) + if err != nil { + continue // unresolved, said by the resolution + } + for _, to := range sortedKeys(trunk.Config) { + decl := trunk.Config[to] + if decl.RenamedFrom == "" { + continue + } + key := renameKey(module, to) + seen[key] = true + said = append(said, c.rename(s, held, trunk, to, decl, now)...) + } + } + // A rename whose `renamed-from` left the trunk's manifest goes once every window is closed. + for key, r := range held { + if seen[key] { + continue + } + if len(r.Open()) > 0 { + s.Renames[key] = r + continue + } + if err := c.Store.Delete(RenamesState, key); err != nil { + said = append(said, fmt.Sprintf("the rename %s is not removed: %v", key, err)) + continue + } + delete(s.Renames, key) + c.clearFinding(FindingRenameRefused, r.Module, r.To) + c.clearFinding(FindingWindowHeld, r.Module, r.To) + } + c.sweepRenameFindings(s, now) + return said +} + +// rename judges and applies one declared rename. +func (c *Config) rename(s *Sources, held map[string]Rename, trunk ModuleDeclarations, to string, decl Declaration, + now time.Time) []string { + module, from := trunk.Module, decl.RenamedFrom + key := renameKey(module, to) + nodes := nodesPlaced(s, module) + var said []string + refuse := func(why string) []string { + c.setFinding(renameRefused(module, to, from, why, now)) + if r, ok := held[key]; ok { + s.Renames[key] = r + } + return []string{fmt.Sprintf("the rename of %s's %s to %s is refused: %s", module, from, to, why)} + } + if _, both := trunk.Config[from]; both { + return refuse("the manifest declares both names") + } + if from == to { + return refuse("the new name is the old one") + } + trusted := decl.Trusted + if p, ok := s.Predefined[decl.Predefined]; ok && p.Trusted { + trusted = true + } + type move struct { + node string + old, new Value + hadNew bool + oldKey string + newKey string + } + var moves []move + for _, node := range nodes { + oldKey, newKey := moduleValueKey(node, module, from), moduleValueKey(node, module, to) + ov, hadOld := s.Values[oldKey] + if !hadOld { + continue + } + nv, hadNew := s.Values[newKey] + switch { + case hadNew && string(compact(nv.Value)) != string(compact(ov.Value)): + return refuse(fmt.Sprintf("on %s both names hold a value, and they differ (%s under the old name, %s under the new)", + node, ov.Value, nv.Value)) + case trusted && ov.Approved == "": + return refuse(fmt.Sprintf("on %s the value under the old name was set without the operator's Approve, and "+ + "the new name is trusted (a rename keeps a value's trust, novox/hq ADR 0348 §8)", node)) + } + moves = append(moves, move{node: node, old: ov, new: nv, hadNew: hadNew, oldKey: oldKey, newKey: newKey}) + } + r, had := held[key] + if !had { + r = Rename{Module: module, From: from, To: to, At: now, Nodes: map[string]Window{}} + } + changed := !had + for _, m := range moves { + if !m.hadNew { + if err := c.Store.Put(ValuesState, m.newKey, m.old); err != nil { + return refuse("the value on " + m.node + " is not written under the new name: " + err.Error()) + } + } + if err := c.Store.Delete(ValuesState, m.oldKey); err != nil { + return refuse("the old name's value on " + m.node + " is not removed: " + err.Error()) + } + s.Values[m.newKey] = m.old + delete(s.Values, m.oldKey) + lvl := Level{Kind: LevelModule, Node: m.node, Module: module} + _ = c.Store.Append(HistoryLog, m.newKey, Change{Action: "rename", Level: &lvl, Key: to, Value: m.old.Value, + Was: m.old.Value, By: Module, Via: Module, Why: "renamed from " + from + " (renamed-from)", At: now}) + if !slices.Contains(r.Moved, m.node) { + r.Moved = append(r.Moved, m.node) + sort.Strings(r.Moved) + } + changed = true + said = append(said, fmt.Sprintf("%s's %s on %s moved to %s", module, from, m.node, to)) + } + for _, node := range nodes { + if _, ok := r.Nodes[node]; !ok { + r.Nodes[node] = Window{Since: now} + changed = true + } + } + c.clearFinding(FindingRenameRefused, module, to) + if !r.Said { + if err := c.Store.Emit(EventRenamed, RenamedEvent{Module: module, From: from, To: to, At: r.At}); err != nil { + said = append(said, fmt.Sprintf("renamed for %s's %s is not said yet: %v", module, to, err)) + } else { + r.Said, changed = true, true + } + } + if changed { + if err := c.Store.Put(RenamesState, key, r); err != nil { + said = append(said, fmt.Sprintf("the rename %s is not kept: %v", key, err)) + } + } + s.Renames[key] = r + return said +} + +// nodesPlaced is the nodes a module is placed on, sorted. +func nodesPlaced(s *Sources, module string) []string { + var out []string + for _, a := range s.Assignments { + if a.Module == module { + out = append(out, a.Node) + } + } + sort.Strings(out) + return slices.Compact(out) +} + +// resolveOldNames adds, for each rename with a window open on a node, the old name answering the new name's value on +// that node (design 32: until then mesh-config answers both names). +func resolveOldNames(s Sources, out map[string]Resolved) { + for _, key := range sortedKeys(s.Renames) { + r := s.Renames[key] + for _, node := range r.Open() { + nv, ok := out[ResolvedKey(node, r.Module, r.To)] + if !ok { + continue + } + old := nv + old.Key = r.From + out[ResolvedKey(node, r.Module, r.From)] = old + } + } +} + +// ---- the windows -------------------------------------------------------------------------------------------- + +// Windows closes the rename windows (and the retirements waiting for nodes) that the delivery says are done, at start +// and on each `delivered`; for node, the one a returning node-engine's `manifest-reads-changed` names, it first asks +// the node-engine seat's `manifest-reads`: a node-engine that does not serve it states nothing and its window stays +// open (design 32). It answers a line for each window closed. +func (c *Config) Windows(node string) []string { + c.mu.Lock() + defer c.mu.Unlock() + said := c.closeWindows(node) + said = append(said, c.drainRetirements(node)...) + if len(said) > 0 { + if _, err := c.reconcileLocked(); err != nil { + said = append(said, "resolving after the windows: "+err.Error()) + } + } + return said +} + +// closeWindows is Windows' rename half; the caller holds the lock. +func (c *Config) closeWindows(only string) []string { + renames, err := c.readRenames() + if err != nil { + return []string{"the renames cannot be read: " + err.Error()} + } + if c.Delivery == nil { + return nil + } + s, err := c.sources() + if err != nil { + return []string{"the windows cannot be judged: " + err.Error()} + } + now := c.now() + var said []string + for _, key := range sortedKeys(renames) { + r := renames[key] + changed := false + for _, node := range r.Open() { + if only != "" && only != node { + continue + } + how, why := c.windowShut(s, r, node, only != "") + if why != "" { + said = append(said, fmt.Sprintf("%s's window for %s stays open on %s: %s", r.Module, r.To, node, why)) + } + if how == "" { + continue + } + w := r.Nodes[node] + w.Closed, w.How = &now, how + r.Nodes[node] = w + changed = true + said = append(said, fmt.Sprintf("%s's window for %s is closed on %s (%s)", r.Module, r.To, node, how)) + } + if !changed { + continue + } + if err := c.Store.Put(RenamesState, key, r); err != nil { + said = append(said, fmt.Sprintf("the rename %s is not kept: %v", key, err)) + } + if len(r.Open()) == 0 { + c.clearFinding(FindingWindowHeld, r.Module, r.To) + } + } + return said +} + +// windowShut says how a node's window may close now ("" when it may not) and, when it may not, why. +func (c *Config) windowShut(s Sources, r Rename, node string, returning bool) (how, why string) { + if !slices.Contains(nodesPlaced(&s, r.Module), node) { + return "unplaced", "" + } + if returning { + if err := c.Delivery.Reads(node); err != nil { + return "", "the node-engine states nothing yet (" + err.Error() + ")" + } + } + fp, found, err := c.Delivery.Have(node, r.Module) + switch { + case err != nil: + return "", "its have cannot be read: " + err.Error() + case !found || fp == "": + return "", "it reports no build of the module" + } + d, err := c.builtDeclarations(r.Module, fp) + if err != nil { + return "", "the build it runs cannot be read: " + err.Error() + } + if _, reads := d.Config[r.To]; !reads { + return "", "it still runs a build that reads " + r.From + } + return "applied", "" +} + +// ---- the tools --------------------------------------------------------------------------------------------- + +// RenameRow is one rename as the tools say it. +type RenameRow struct { + Module string `json:"module"` + From string `json:"from"` + To string `json:"to"` + At time.Time `json:"at"` + Moved []string `json:"moved,omitempty"` + // Open are the nodes holding the old name open; Closed the others, each with how. + Open []string `json:"open"` + Closed map[string]string `json:"closed,omitempty"` +} + +// Renames lists the renames, the open ones only for the seat's verb, narrowed by module. +func (c *Config) Renames(module string, openOnly bool) ([]RenameRow, error) { + renames, err := c.readRenames() + if err != nil { + return nil, err + } + rows := []RenameRow{} + for _, key := range sortedKeys(renames) { + r := renames[key] + if module != "" && r.Module != module { + continue + } + row := RenameRow{Module: r.Module, From: r.From, To: r.To, At: r.At, Moved: r.Moved, Open: r.Open()} + if row.Open == nil { + row.Open = []string{} + } + if openOnly && len(row.Open) == 0 { + continue + } + for node, w := range r.Nodes { + if w.Closed != nil { + if row.Closed == nil { + row.Closed = map[string]string{} + } + row.Closed[node] = w.How + } + } + rows = append(rows, row) + } + return rows, nil +} + +// RenameAgain moves a rename's values now, the way a resolution does, and answers what it did: the recovery for a +// refused move once its cause is put right. +func (c *Config) RenameAgain(module, to string) (any, error) { + c.mu.Lock() + defer c.mu.Unlock() + if module == "" || to == "" { + return nil, fmt.Errorf("name the module and the new name of the rename") + } + r, err := c.reconcileLocked() + if err != nil { + return r, err + } + renames, err := c.readRenames() + if err != nil { + return nil, err + } + if _, ok := renames[renameKey(module, to)]; !ok { + return nil, fmt.Errorf("no rename of %s to %s is kept: its trunk manifest declares no renamed-from for %s, or the rename is refused (config_renames and the findings say which)", module, to, to) + } + return map[string]any{"said": r.Renamed, "moved": r.Moved}, nil +} + +// RenameClose drops a node from a rename's window by hand, recorded as a hand act. +func (c *Config) RenameClose(module, to, node, why string) (any, error) { + if strings.TrimSpace(why) == "" { + return nil, fmt.Errorf("say why the window is dropped: a hand act keeps its why") + } + c.mu.Lock() + defer c.mu.Unlock() + key := renameKey(module, to) + renames, err := c.readRenames() + if err != nil { + return nil, err + } + r, ok := renames[key] + if !ok { + return nil, fmt.Errorf("no rename of %s to %s is kept", module, to) + } + w, ok := r.Nodes[node] + if !ok || w.Closed != nil { + return nil, fmt.Errorf("%s holds no open window for %s's %s; it holds: %s", node, module, to, strings.Join(r.Open(), ", ")) + } + now := c.now() + w.Closed, w.How = &now, "by hand" + r.Nodes[node] = w + if err := c.Store.HandAct(fmt.Sprintf("config_rename_close %s %s on %s", module, to, node), why, + "the operator dropped a node from the rename window of "+key); err != nil { + return nil, fmt.Errorf("the hand-act log does not hold it, so the window is not dropped: %w", err) + } + if err := c.Store.Put(RenamesState, key, r); err != nil { + return nil, fmt.Errorf("the window is not dropped: %w", err) + } + if len(r.Open()) == 0 { + c.clearFinding(FindingWindowHeld, r.Module, r.To) + } + if _, err := c.reconcileLocked(); err != nil { + return nil, err + } + return map[string]any{"closed": node, "open": r.Open()}, nil +} + +// sweepRenameFindings writes a finding for each window held open past HeldFinding, naming the node, and clears one +// whose window closed. +func (c *Config) sweepRenameFindings(s *Sources, now time.Time) { + for _, key := range sortedKeys(s.Renames) { + r := s.Renames[key] + var held []string + for _, node := range r.Open() { + if now.Sub(r.Nodes[node].Since) >= HeldFinding { + held = append(held, node) + } + } + c.clearFinding(FindingWindowHeld, r.Module, r.To, held...) + for _, node := range held { + c.setFinding(windowHeld(r, node, now)) + } + } +} diff --git a/modules/mesh-config/cmd/mesh-config/renames_test.go b/modules/mesh-config/cmd/mesh-config/renames_test.go new file mode 100644 index 000000000..e4b5d3a27 --- /dev/null +++ b/modules/mesh-config/cmd/mesh-config/renames_test.go @@ -0,0 +1,437 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "testing" + "time" + + meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" +) + +// fakeDelivery is the delivery parts as mesh-config asks them: each node's have, whether a node-engine states what it +// reads, and who reads a module's keys (nil rows and known false: the indexer does not serve the tool yet). +type fakeDelivery struct { + mu sync.Mutex + haves map[string]string // "." → fingerprint + notSays map[string]bool // node → its node-engine states nothing + rows []ReaderRow + known bool +} + +func (d *fakeDelivery) Have(node, module string) (string, bool, error) { + d.mu.Lock() + defer d.mu.Unlock() + fp, ok := d.haves[node+"."+module] + return fp, ok, nil +} + +func (d *fakeDelivery) Reads(node string) error { + d.mu.Lock() + defer d.mu.Unlock() + if d.notSays[node] { + return errors.New("the verb manifest-reads is not served") + } + return nil +} + +func (d *fakeDelivery) Readers(string) ([]ReaderRow, bool, error) { + d.mu.Lock() + defer d.mu.Unlock() + return d.rows, d.known, nil +} + +// fakeVault records the retirements asked of the seat mesh-vault. +type fakeVault struct{ asked []RetireRequest } + +func (v *fakeVault) Retire(r RetireRequest) error { v.asked = append(v.asked, r); return nil } + +// The fingerprints of the first, second and third build of player. +var ( + fp1 = "src2:" + strings.Repeat("1", 64) + fp2 = "src2:" + strings.Repeat("2", 64) + fp3 = "src2:" + strings.Repeat("3", 64) +) + +const ( + oldPlayer = `{"module":"player","config":{"volume":{"type":"number","default":5}}}` + newPlayer = `{"module":"player","config":{"level":{"type":"number","default":5,"renamed-from":"volume"}}}` + // donePlayer is the manifest once `renamed-from` left it. + donePlayer = `{"module":"player","config":{"level":{"type":"number","default":5}}}` +) + +// aRenamingMesh is a mesh with player on g14 and novox, both running its first build (reading `volume`), a clock the +// test moves, and the delivery parts faked. +func aRenamingMesh(t *testing.T) (*Config, *memStore, *fakeDelivery, *time.Time) { + t.Helper() + m := newStore() + m.declare(t, "player", oldPlayer) + indexerList(t, m, "aaaaaaaaaaaa", theList) + for _, node := range []string{"g14", "novox"} { + m.assign(t, node, "player") + m.want(t, node, "player", fp1, oldPlayer) + } + clock := time.Date(2026, 11, 4, 10, 0, 0, 0, time.UTC) + d := &fakeDelivery{haves: map[string]string{"g14.player": fp1, "novox.player": fp1}, notSays: map[string]bool{}} + c := &Config{Store: m, Now: func() time.Time { return clock }, Logf: t.Logf, Delivery: d} + if _, err := c.Reconcile(); err != nil { + t.Fatal(err) + } + return c, m, d, &clock +} + +func findingsOf(t *testing.T, m *memStore, kind string) []meshconditions.FindingsByKeyValue { + t.Helper() + var out []meshconditions.FindingsByKeyValue + for _, k := range m.keysOf(FindingsState) { + e, _ := m.Get(FindingsState, k) + f, err := meshconditions.DecodeFindingsByKeyValue(e.Value) + if err != nil { + t.Fatalf("finding %s: %v", k, err) + } + if f.Kind == kind { + out = append(out, f) + } + } + return out +} + +// **A renamed value moves with its trust, both names answer until the node-engines run the new build, and the window +// closes per node** (design 32, C20): the value set under the old name is under the new one with no operator input, +// `renamed` is said once, the old name answers the new value on a node until that node's have runs a build that reads the +// new name (on `delivered`, or for a returning node on `manifest-reads-changed` once its node-engine states what it +// reads), and the rename goes once every window is closed and `renamed-from` left the manifest. +func TestARenamedValueMovesAndTheOldNameAnswersUntilEachNodeRunsTheNewBuild(t *testing.T) { + c, m, d, _ := aRenamingMesh(t) + set(t, c, Call{Key: "volume", Value: 8.0, Module: "player", Node: "g14"}) + m.declare(t, "player", newPlayer) + r, err := c.Reconcile() + if err != nil || len(r.Renamed) != 1 { + t.Fatalf("reconcile: %+v, %v", r, err) + } + if v, ok := storedValue(t, m, "module.g14.player.level"); !ok || string(v.Value) != "8" || v.By == "" { + t.Errorf("the value is %+v under the new name (set: %v)", v, ok) + } + if _, ok := storedValue(t, m, "module.g14.player.volume"); ok { + t.Error("the old name still holds a value") + } + for _, c := range []struct{ node, key, want string }{{"g14", "level", "8"}, {"g14", "volume", "8"}, {"novox", "level", "5"}, {"novox", "volume", "5"}} { + if r := m.resolved(t, c.node, "player", c.key); string(r.Value) != c.want { + t.Errorf("%s answers %s on %s as %s, want %s", c.key, "player", c.node, r.Value, c.want) + } + } + if got := m.saidOf(EventRenamed); len(got) != 1 || got[0].(RenamedEvent).From != "volume" || got[0].(RenamedEvent).To != "level" { + t.Errorf("renamed was said as %+v", got) + } + // The history keeps the move; a second pass says and moves nothing more. + if h, _ := c.History(Call{Module: "player", Node: "g14"}); len(h) == 0 || h[len(h)-1].Action != "resolve" && h[len(h)-1].Action != "rename" { + t.Errorf("history %+v", h) + } + if _, err := c.Reconcile(); err != nil || len(m.saidOf(EventRenamed)) != 1 { + t.Errorf("a second pass said renamed %d times: %v", len(m.saidOf(EventRenamed)), err) + } + rows, _ := c.Renames("", true) + if len(rows) != 1 || strings.Join(rows[0].Open, ",") != "g14,novox" || strings.Join(rows[0].Moved, ",") != "g14" { + t.Fatalf("renames %+v", rows) + } + + // A delivery while both nodes still run the first build closes nothing. + if said := c.Windows(""); len(said) != 2 || !strings.Contains(said[0], "still runs a build that reads volume") { + t.Errorf("windows said %v", said) + } + // g14 applies the new build. + m.want(t, "g14", "player", fp2, newPlayer) + d.haves["g14.player"] = fp2 + if said := c.Windows(""); len(said) == 0 { + t.Error("g14 applied the new build and its window did not close") + } + if _, err := m.Get(ResolvedState, ResolvedKey("g14", "player", "volume")); err != nil { + t.Fatal(err) + } else if e, _ := m.Get(ResolvedState, ResolvedKey("g14", "player", "volume")); e != nil { + t.Error("g14 still answers the old name after its window closed") + } + m.resolved(t, "novox", "player", "volume") // novox still answers it + // novox applies it, but its node-engine, returning, states nothing yet: the window stays open. + m.want(t, "novox", "player", fp2, newPlayer) + d.haves["novox.player"] = fp2 + d.notSays["novox"] = true + c.Windows("novox") + if rows, _ := c.Renames("", true); len(rows) != 1 || strings.Join(rows[0].Open, ",") != "novox" { + t.Errorf("a node-engine that states nothing closed its window: %+v", rows) + } + d.notSays["novox"] = false + c.Windows("novox") + if rows, _ := c.Renames("", true); len(rows) != 0 { + t.Errorf("windows still open: %+v", rows) + } + // The rename stays while `renamed-from` is in the manifest, and goes when it left. + if all, _ := c.Renames("", false); len(all) != 1 || all[0].Closed["g14"] != "applied" { + t.Errorf("renames %+v", all) + } + m.declare(t, "player", donePlayer) + if _, err := c.Reconcile(); err != nil { + t.Fatal(err) + } + if all, _ := c.Renames("", false); len(all) != 0 { + t.Errorf("the rename stayed after renamed-from left the manifest: %+v", all) + } +} + +// A refused move is a finding, cleared once it moved (C4): both names declared, both names holding different values, a +// trusted new name over a value set without the operator's Approve. Nothing is moved, nothing is said. +func TestARefusedRenameIsAFindingAndIsClearedOnceItMoves(t *testing.T) { + both := `{"module":"player","config":{"volume":{"type":"number","default":5},"level":{"type":"number","default":5,"renamed-from":"volume"}}}` + trusted := `{"module":"player","config":{"level":{"type":"number","default":5,"trusted":true,"renamed-from":"volume"}}}` + for _, tc := range []struct { + name, manifest string + both bool + why string + fixed string + }{ + {"both names declared", both, false, "declares both names", newPlayer}, + {"both names hold a value", newPlayer, true, "both names hold a value", newPlayer}, + {"a trusted name over an unapproved value", trusted, false, "without the operator's Approve", newPlayer}, + } { + t.Run(tc.name, func(t *testing.T) { + c, m, _, _ := aRenamingMesh(t) + set(t, c, Call{Key: "volume", Value: 8.0, Module: "player", Node: "g14"}) + if tc.both { + // The new name is set meanwhile, to another value, in the state a faulty hand wrote it. + if err := m.Put(ValuesState, "module.g14.player.level", Value{Value: json.RawMessage("9"), By: "x", At: time.Now()}); err != nil { + t.Fatal(err) + } + } + m.declare(t, "player", tc.manifest) + if _, err := c.Reconcile(); err != nil { + t.Fatal(err) + } + fs := findingsOf(t, m, FindingRenameRefused) + if len(fs) != 1 || !strings.Contains(fs[0].Summary, tc.why) || fs[0].Observer != Module { + t.Fatalf("findings %+v, want one saying %q", fs, tc.why) + } + if _, ok := storedValue(t, m, "module.g14.player.volume"); !ok || len(m.saidOf(EventRenamed)) != 0 { + t.Errorf("a refused rename moved the value or said renamed") + } + // Put right, it moves by hand and the finding goes. + m.declare(t, "player", tc.fixed) + if tc.both { + _ = m.Delete(ValuesState, "module.g14.player.level") + } + if _, err := c.RenameAgain("player", "level"); err != nil { + t.Fatal(err) + } + if fs := findingsOf(t, m, FindingRenameRefused); len(fs) != 0 { + t.Errorf("the finding stayed after the move: %+v", fs) + } + if v, ok := storedValue(t, m, "module.g14.player.level"); !ok || string(v.Value) != "8" { + t.Errorf("the value is %+v after the move", v) + } + }) + } +} + +// A window held open seven days is a finding naming the node, cleared when it closes; dropping a node by hand is a hand +// act with its why (C4, C5), and the seat's verb `renames` names the nodes holding a window open. +func TestAWindowHeldSevenDaysIsAFindingAndAHandCloseIsAHandAct(t *testing.T) { + c, m, d, clock := aRenamingMesh(t) + m.declare(t, "player", newPlayer) + if _, err := c.Reconcile(); err != nil { + t.Fatal(err) + } + m.want(t, "g14", "player", fp2, newPlayer) + d.haves["g14.player"] = fp2 + c.Windows("") + *clock = clock.Add(8 * 24 * time.Hour) + if _, err := c.Reconcile(); err != nil { + t.Fatal(err) + } + fs := findingsOf(t, m, FindingWindowHeld) + if len(fs) != 1 || fs[0].Subject.Machine != "novox" || !strings.Contains(fs[0].Summary, "novox") { + t.Fatalf("findings %+v, want one naming novox", fs) + } + // The seat's verb names the node holding the window open. + var verb func(map[string]any) (any, error) + for _, tool := range Tools(c) { + if tool.Name == Seat+"."+RenamesVerb { + verb = tool.Run + } + } + got, err := verb(map[string]any{"module": "player"}) + if raw, _ := json.Marshal(got); err != nil || !strings.Contains(string(raw), `"open":["novox"]`) { + t.Errorf("renames answered %s, %v", raw, err) + } + if _, err := c.RenameClose("player", "level", "novox", ""); err == nil { + t.Error("a window was dropped with no why") + } + if _, err := c.RenameClose("player", "level", "g14", "g14 is closed already"); err == nil { + t.Error("a node holding no open window was dropped") + } + m.handActs = nil + if _, err := c.RenameClose("player", "level", "novox", "novox is retired for good"); err != nil { + t.Fatal(err) + } + if len(m.handActs) != 1 || !strings.Contains(m.handActs[0], "novox is retired for good") { + t.Errorf("hand acts %v", m.handActs) + } + if fs := findingsOf(t, m, FindingWindowHeld); len(fs) != 0 { + t.Errorf("the finding stayed after the window closed: %+v", fs) + } + if rows, _ := c.Renames("", true); len(rows) != 0 { + t.Errorf("open renames %+v", rows) + } +} + +// retiringMesh is a mesh where player no longer declares `volume`, a value of it is set on g14, and g14 runs the first +// build (which reads it). +func retiringMesh(t *testing.T) (*Config, *memStore, *fakeDelivery, *standIn, *fakeVault, *time.Time) { + t.Helper() + c, m, d, clock := aRenamingMesh(t) + set(t, c, Call{Key: "volume", Value: 8.0, Module: "player", Node: "g14"}) + m.declare(t, "player", donePlayer) + r := newStandIn() + v := &fakeVault{} + c.Asks, c.Vault = r, v + d.known = true + d.rows = []ReaderRow{{Module: "player", Key: "volume", Kind: KindValue, Readers: []string{}}} + return c, m, d, r, v, clock +} + +func retiringOf(t *testing.T, c *Config) []RetiringRow { + t.Helper() + got, err := c.ConfigRetiring("", "", "") + if err != nil { + t.Fatal(err) + } + return got.(map[string]any)["retiring"].([]RetiringRow) +} + +// **A value no manifest reads is retired only on the operator's Approve** (C24): while a node runs a build that reads it +// it is not marked and nobody is asked; once none does, `retiring` is said and the operator is asked once; the value stays +// until the Approve, then it is removed, `retired` is said for the node it was set on, and the warrant is recorded. +func TestAValueNoManifestReadsIsRetiredOnlyOnTheOperatorsApprove(t *testing.T) { + c, m, d, r, _, _ := retiringMesh(t) + // The indexer does not serve the tool yet: nothing retires on that. + d.known = false + if said := c.Retirements("player"); len(said) != 1 || !strings.Contains(said[0], "does not say") || len(retiringOf(t, c)) != 0 { + t.Errorf("an indexer without the tool: %v", said) + } + d.known = true + // g14 still runs a build that reads volume: draining, no ask, no event. + c.Retirements("player") + if rows := retiringOf(t, c); len(rows) != 1 || rows[0].State != RetireDraining || strings.Join(rows[0].Nodes, ",") != "g14,novox" { + t.Fatalf("retiring %+v, want draining on g14 and novox", rows) + } + if len(r.asks) != 0 || len(m.saidOf(EventRetiring)) != 0 { + t.Fatalf("asked %d times and said retiring %d times while two nodes read the value", len(r.asks), len(m.saidOf(EventRetiring))) + } + // Both nodes apply the build that reads it no longer. + for _, node := range []string{"g14", "novox"} { + m.want(t, node, "player", fp3, donePlayer) + d.haves[node+".player"] = fp3 + } + c.Windows("") + rows := retiringOf(t, c) + if len(rows) != 1 || rows[0].State != RetireAsked || len(r.asks) != 1 || len(m.saidOf(EventRetiring)) != 1 { + t.Fatalf("retiring %+v, %d asks, %d events", rows, len(r.asks), len(m.saidOf(EventRetiring))) + } + if _, ok := storedValue(t, m, "module.g14.player.volume"); !ok { + t.Fatal("the value was removed before the operator answered") + } + // A second manifest-accepted asks nobody again. + c.Retirements("player") + if len(r.asks) != 1 { + t.Errorf("asked %d times", len(r.asks)) + } + r.choose(t, rows[0].Ask, OptionApprove, nil) + m.handActs = nil + if said, err := c.Decide(); err != nil || len(said) == 0 { + t.Fatalf("decide: %v, %v", said, err) + } + if _, ok := storedValue(t, m, "module.g14.player.volume"); ok { + t.Error("the approved value is still set") + } + if got := m.saidOf(EventRetired); len(got) != 1 || got[0].(RetiredEvent).Node != "g14" || got[0].(RetiredEvent).Name != "volume" { + t.Errorf("retired was said as %+v", got) + } + if len(retiringOf(t, c)) != 0 || len(m.handActs) == 0 { + t.Errorf("retiring %+v, hand acts %v", retiringOf(t, c), m.handActs) + } +} + +// A declined retirement is not asked again until the value is read again; a value read again is dropped; an ask unanswered +// is asked once more after seven days and then written as a finding, cleared by an answer or a cancel; a secret is +// retired through the seat mesh-vault with the ask that warranted it (C24, C7). +func TestARetirementDeclinedUnansweredCancelledOrASecret(t *testing.T) { + c, m, d, r, v, clock := retiringMesh(t) + for _, node := range []string{"g14", "novox"} { + m.want(t, node, "player", fp3, donePlayer) + d.haves[node+".player"] = fp3 + } + c.Retirements("player") + ask := retiringOf(t, c)[0].Ask + + // Unanswered: the ask expires, a week passes, it is asked once more, expires again: a finding. + r.choose(t, ask, OptionApprove, func(w *oc.Warrant) { w.Outcome = occ.OutcomeExpired }) + c.Decide() + if rows := retiringOf(t, c); rows[0].State != RetireUnanswered || len(findingsOf(t, m, FindingRetireUnanswered)) != 0 { + t.Fatalf("after the first expiry: %+v", rows) + } + *clock = clock.Add(8 * 24 * time.Hour) + c.Decide() + rows := retiringOf(t, c) + if rows[0].State != RetireAsked || len(r.asks) != 2 || rows[0].Ask == ask { + t.Fatalf("after a week: %+v, %d asks", rows, len(r.asks)) + } + r.choose(t, rows[0].Ask, OptionApprove, func(w *oc.Warrant) { w.Outcome = occ.OutcomeExpired }) + c.Decide() + if fs := findingsOf(t, m, FindingRetireUnanswered); len(fs) != 1 { + t.Fatalf("findings %+v after two asks unanswered", fs) + } + // Asked again by hand, then declined: the finding goes and it is not asked again. + if _, err := c.ConfigRetiring("ask-again", "player", "volume"); err != nil { + t.Fatal(err) + } + third := retiringOf(t, c)[0].Ask + r.choose(t, third, OptionDecline, nil) + c.Decide() + if rows := retiringOf(t, c); rows[0].State != RetireDeclined || len(findingsOf(t, m, FindingRetireUnanswered)) != 0 { + t.Fatalf("after the decline: %+v", rows) + } + asked := len(r.asks) + c.Retirements("player") + if len(r.asks) != asked { + t.Error("a declined retirement was asked again") + } + // Read again: dropped. + d.rows = []ReaderRow{{Module: "player", Key: "volume", Kind: KindValue, Readers: []string{"player"}}} + if said := c.Retirements("player"); len(said) != 1 || !strings.Contains(said[0], "read again") || len(retiringOf(t, c)) != 0 { + t.Errorf("read again: %v", said) + } + + // A secret: its nodes come from the indexer's answer; on the Approve the vault is asked for each, once. + d.rows = []ReaderRow{{Module: "player", Key: "token", Kind: KindSecret, Readers: []string{}, Nodes: []string{}}} + c.Retirements("player") + secret := retiringOf(t, c)[0] + if secret.Kind != KindSecret || secret.State != RetireAsked { + t.Fatalf("the secret: %+v", secret) + } + r.choose(t, secret.Ask, OptionApprove, nil) + c.Decide() + if len(v.asked) != 2 || v.asked[0].Node != "g14" || v.asked[0].Name != "token" || v.asked[0].Ask != secret.Ask { + t.Errorf("the vault was asked %+v", v.asked) + } + if len(m.saidOf(EventRetired)) != 2 || len(retiringOf(t, c)) != 0 { + t.Errorf("retired %d times, retiring %+v", len(m.saidOf(EventRetired)), retiringOf(t, c)) + } + // A secret whose readers' nodes the indexer cannot name is not marked. + d.rows = []ReaderRow{{Module: "player", Key: "other", Kind: KindSecret, Readers: []string{}}} + if c.Retirements("player"); len(retiringOf(t, c)) != 0 { + t.Errorf("a secret read on unknown nodes was marked: %+v", retiringOf(t, c)) + } + _ = fmt.Sprint +} diff --git a/modules/mesh-config/cmd/mesh-config/resolve.go b/modules/mesh-config/cmd/mesh-config/resolve.go index f911c225c..ca32919c0 100644 --- a/modules/mesh-config/cmd/mesh-config/resolve.go +++ b/modules/mesh-config/cmd/mesh-config/resolve.go @@ -3,11 +3,12 @@ package main import ( "encoding/json" "fmt" + meshseat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "sort" "strings" "time" - - "git.novox.be/novox/mesh-sdk/go/delivery" ) // The four sources (novox/hq ADR 0348 §2, §3, §9): a module's value on a node resolves, the most specific winning, @@ -22,18 +23,18 @@ const ( PredefinedState = "predefined" ProposalsState = "proposals" - // PlacementRead is the seat mesh-placement's state, held by the assigner (novox/hq ADR 0567 §1, §8): each - // node's key `placement.` with the modules placed there. It is the one source of which module runs where + // AssignmentsRead is the seat mesh-module-assigner's state, held by the assigner (novox/hq ADR 0567 §1, §8): each + // node's key `.node` with the modules placed there. It is the one source of which module runs where // (issue 1953): the controller's assignments are no longer read. - PlacementRead = delivery.PlacementRead + AssignmentsRead = (assignerModule + "." + meshseat.StateAssignments) // WantsRead is the assigner's wants, `want..`: the build each node is to run (ADR 0539 §1). - WantsRead = delivery.AssignerModule + ".wants" + WantsRead = assignerModule + ".wants" // BuildsRead is the builder's builds, `.`: each build's manifest digest (ADR 0525). - BuildsRead = delivery.BuilderModule + ".builds" + BuildsRead = builds.BuilderModule + ".builds" // ModulesRead is the indexer's modules, ``: the trunk's manifest digest of each module, from whichever // repository holds it (ADR 0437 §5, ADR 0478 §9); ManifestsRead its manifests by digest (ADR 0429 §16). - ModulesRead = delivery.IndexerModule + ".modules" - ManifestsRead = delivery.IndexerModule + ".manifests" + ModulesRead = indexerModule + "." + catalog.StateModules + ManifestsRead = indexerModule + "." + catalog.StateManifests // HistoryLog is the log of every set and clear, with the proven caller and why (ADR 0348 §9). HistoryLog = "history" @@ -150,6 +151,8 @@ type Sources struct { // for them (pulls.go); neither is resolved. Pulls map[string]PullDecl Kept map[string]KeptValue + // Renames are the renames kept, by `.`, whose open windows answer the old name (renames.go). + Renames map[string]Rename } // Unresolved is a module placed on a node whose values are not resolved, and why. @@ -192,6 +195,7 @@ func Resolve(s Sources) map[string]Resolved { out[ResolvedKey(a.Node, a.Module, key)] = r } } + resolveOldNames(s, out) return out } diff --git a/modules/mesh-config/cmd/mesh-config/retiring.go b/modules/mesh-config/cmd/mesh-config/retiring.go new file mode 100644 index 000000000..dc010dd74 --- /dev/null +++ b/modules/mesh-config/cmd/mesh-config/retiring.go @@ -0,0 +1,582 @@ +package main + +// A value no longer read (novox/hq ADR 0609, design 32 *A value no longer read*). +// +// A config value or secret that no manifest on any trunk reads any more is a **retiring value**. mesh-config learns it +// from the indexer's tool `indexer_config_readers`, asked on each of the indexer's `manifest-accepted` (Retirements); +// a tool the indexer does not serve yet answers "not known" and nothing retires. A value is marked retiring only once no +// node-engine reports applied a build that reads it (State draining until then, judged again on each `delivered`); +// mesh-config then says `retiring` and asks the operator once, through the operator channel, whether to remove it. It is +// removed only on the operator's Approve: a value by mesh-config, a secret through the seat `mesh-vault`'s verb +// `retire`. An ask unanswered after seven days is asked once more, and then written as a finding. + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "slices" + "sort" + "strings" + "time" + + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" +) + +// The state, and where a retirement stands. +const ( + RetiringState = "retiring" + // RetireVerb is the act an answer binds. + RetireVerb = Seat + ".retire" + + RetireDraining = "draining" // a node still runs a build that reads it + RetireAsked = "asked" // the operator is asked + RetireUnanswered = "unanswered" // the ask ended with no choice + RetireApproved = "approved" // the operator approved; being removed + RetireDeclined = "declined" // the operator declined, or a person cancelled: kept until the value is read again +) + +// Kinds of retiring value. +const ( + KindValue = "value" + KindSecret = "secret" +) + +// Retiring is one retiring value, as `retiring` keeps it, key `.`. +type Retiring struct { + Module string `json:"module"` + Key string `json:"key"` + Kind string `json:"kind"` + Since time.Time `json:"since"` + State string `json:"state"` + // Nodes are the nodes that still read it (draining) or that it is removed on (asked, approved). + Nodes []string `json:"nodes,omitempty"` + // Ask is the open ask's id, Asked when the first ask was made, AskedAgain whether it was asked once more. + Ask string `json:"ask,omitempty"` + // AskedAt is when the open ask was made (it expires a day on), Asked when the first was. + AskedAt time.Time `json:"asked-at,omitempty"` + Asked time.Time `json:"asked,omitempty"` + AskedAgain bool `json:"asked-again,omitempty"` + Said bool `json:"said,omitempty"` + Outcome string `json:"outcome,omitempty"` + Answered string `json:"answered,omitempty"` + // Removed are the nodes it is removed on already. + Removed []string `json:"removed,omitempty"` +} + +func retiringKey(module, key string) string { return module + "." + key } + +// RetiringEvent is the body of `retiring`; RetiredEvent of `retired`. +type RetiringEvent struct { + Module string `json:"module"` + Key string `json:"key"` + Kind string `json:"kind"` + Since time.Time `json:"since"` + Ask string `json:"ask"` +} + +// RetiredEvent is the body of `retired`: one node's value removed, with the ask that warranted it. +type RetiredEvent struct { + Module string `json:"module"` + Name string `json:"name"` + Node string `json:"node"` + Ask string `json:"ask"` +} + +// ReaderRow is one key the indexer's `indexer_config_readers` answers: the modules on each trunk that declare or refer +// to it. For a secret, Nodes are the nodes whose applied build reads it, when the indexer can say. +type ReaderRow struct { + Module string `json:"module"` + Key string `json:"key"` + Kind string `json:"kind"` + Readers []string `json:"readers"` + Nodes []string `json:"nodes,omitempty"` +} + +// Delivery is what the delivery parts answer to this module: a node's have, a node-engine's statement of what it +// reads, and who reads a module's config keys. Each is a tool or a seat verb on the bus, never a bucket (ADR 0596). +type Delivery interface { + // Have is the fingerprint of the build node runs of module, as the status view's node_haves says it. + Have(node, module string) (fp string, found bool, err error) + // Reads asks the node-engine seat's manifest-reads for node: an error when it is not served or the node says nothing. + Reads(node string) error + // Readers asks the indexer who reads module's keys: known false when the indexer does not serve the tool yet. + Readers(module string) (rows []ReaderRow, known bool, err error) +} + +// RetireRequest is what the vault's verb `retire` takes: one module's own secret on a node, with the hand act that +// warrants it. +type RetireRequest struct { + Node, Module, Name, Ask string +} + +// Vault is the seat mesh-vault as this module reaches it. +type Vault interface { + Retire(r RetireRequest) error +} + +func (c *Config) readRetiring() (map[string]Retiring, error) { + out := map[string]Retiring{} + err := c.each(RetiringState, func(key string, raw []byte) error { + var r Retiring + if json.Unmarshal(raw, &r) != nil || r.Module == "" || retiringKey(r.Module, r.Key) != key { + return nil + } + out[key] = r + return nil + }) + return out, err +} + +// Retirements judges a module's keys on the indexer's word that its manifest was accepted. It answers a line for each +// decision. +func (c *Config) Retirements(module string) []string { + c.mu.Lock() + defer c.mu.Unlock() + if c.Delivery == nil || module == "" { + return nil + } + rows, known, err := c.Delivery.Readers(module) + if err != nil { + return []string{fmt.Sprintf("who reads %s's config is not asked yet: %v", module, err)} + } + if !known { + return []string{fmt.Sprintf("the indexer does not say who reads %s's config yet; nothing retires on that", module)} + } + held, err := c.readRetiring() + if err != nil { + return []string{"the retirements cannot be read: " + err.Error()} + } + s, err := c.sources() + if err != nil { + return []string{"the retirements cannot be judged: " + err.Error()} + } + var said []string + for _, row := range rows { + if row.Module != "" && row.Module != module { + continue + } + row.Module = module + if row.Kind == "" { + row.Kind = KindValue + } + key := retiringKey(module, row.Key) + e, has := held[key] + if len(row.Readers) > 0 { + if has { + said = append(said, c.dropRetiring(e, "it is read again")) + } + continue + } + if has { + continue // already marked; it is advanced by drainRetirements and Decide + } + if row.Kind == KindValue && !valueHeld(s, module, row.Key) { + continue // nothing set under that name: nothing to remove + } + if row.Kind == KindSecret && row.Nodes == nil && !row.nodesKnown() { + continue // which nodes read a secret is not known, and nothing retires on that + } + e = Retiring{Module: module, Key: row.Key, Kind: row.Kind, Since: c.now(), State: RetireDraining, Nodes: row.Nodes} + said = append(said, c.advanceRetiring(s, &e)...) + } + return said +} + +func (r ReaderRow) nodesKnown() bool { return r.Nodes != nil } + +// valueHeld is whether a module's own value is set under key on any node. +func valueHeld(s Sources, module, key string) bool { + for k := range s.Values { + if l, kk, ok := parseValueKey(k); ok && l.Kind == LevelModule && l.Module == module && kk == key { + return true + } + } + return false +} + +// nodesReading is the nodes whose applied build still reads a value (secrets: the nodes the indexer last named). +func (c *Config) nodesReading(s Sources, e Retiring) []string { + if e.Kind != KindValue || c.Delivery == nil { + return e.Nodes + } + var out []string + for _, node := range nodesPlaced(&s, e.Module) { + fp, found, err := c.Delivery.Have(node, e.Module) + if err != nil || !found || fp == "" { + if err != nil { + out = append(out, node) // not known is not "no longer read" + } + continue + } + d, err := c.builtDeclarations(e.Module, fp) + if err != nil { + out = append(out, node) + continue + } + if _, reads := d.Config[e.Key]; reads { + out = append(out, node) + } + } + sort.Strings(out) + return out +} + +// advanceRetiring moves a draining retirement on: while a node reads the value it stays draining (and is written, so a +// delivery judges it again); once none does it is marked retiring, said, and asked. The caller holds the lock. +func (c *Config) advanceRetiring(s Sources, e *Retiring) []string { + key := retiringKey(e.Module, e.Key) + now := c.now() + if e.State == RetireDraining { + e.Nodes = c.nodesReading(s, *e) + if len(e.Nodes) > 0 { + if err := c.Store.Put(RetiringState, key, *e); err != nil { + return []string{fmt.Sprintf("the retirement %s is not kept: %v", key, err)} + } + if now.Sub(e.Since) >= HeldFinding { + c.setFinding(retirementHeld(*e, now)) + } + return []string{fmt.Sprintf("%s's %s is not read by any manifest, and %s still run a build that reads it", + e.Module, e.Key, strings.Join(e.Nodes, ", "))} + } + c.clearFinding(FindingRetireHeld, e.Module, e.Key) + e.Nodes = nodesPlaced(&s, e.Module) + if err := c.ask(e, false); err != nil { + return []string{fmt.Sprintf("the operator is not asked about retiring %s's %s: %v", e.Module, e.Key, err)} + } + } + if !e.Said { + if err := c.Store.Emit(EventRetiring, RetiringEvent{Module: e.Module, Key: e.Key, Kind: e.Kind, Since: e.Since, + Ask: e.Ask}); err == nil { + e.Said = true + } + } + if err := c.Store.Put(RetiringState, key, *e); err != nil { + return []string{fmt.Sprintf("the retirement %s is not kept: %v", key, err)} + } + return []string{fmt.Sprintf("%s's %s is retiring: the operator is asked whether to remove it", e.Module, e.Key)} +} + +// drainRetirements judges the draining retirements again, after a delivery or a returning node; the caller holds the lock. +func (c *Config) drainRetirements(string) []string { + held, err := c.readRetiring() + if err != nil || len(held) == 0 || c.Delivery == nil { + return nil + } + s, err := c.sources() + if err != nil { + return []string{"the retirements cannot be judged: " + err.Error()} + } + var said []string + for _, key := range sortedKeys(held) { + e := held[key] + if e.State == RetireDraining { + said = append(said, c.advanceRetiring(s, &e)...) + } + } + return said +} + +// dropRetiring ends a retirement: the value is read again, or a person cancelled it. +func (c *Config) dropRetiring(e Retiring, why string) string { + if err := c.Store.Delete(RetiringState, retiringKey(e.Module, e.Key)); err != nil { + return fmt.Sprintf("the retirement of %s's %s is not dropped: %v", e.Module, e.Key, err) + } + c.clearFinding(FindingRetireUnanswered, e.Module, e.Key) + c.clearFinding(FindingRetireHeld, e.Module, e.Key) + return fmt.Sprintf("%s's %s is no longer retiring: %s", e.Module, e.Key, why) +} + +// ---- the ask ------------------------------------------------------------------------------------------------ + +// retireAskID is an ask's id: the same retirement asked again is another id. +func retireAskID(e Retiring, again bool) string { + sum := sha256.Sum256([]byte(e.Module + "\x00" + e.Key + "\x00" + e.Since.UTC().Format(time.RFC3339Nano))) + id := "ret-" + hex.EncodeToString(sum[:6]) + if again { + id += "-2" + } + return id +} + +// act is what an answer binds: the retirement, the nodes it reaches, the ask and the answer. +func (e Retiring) act(ask, option string) occ.Act { + return occ.Act{"verb": RetireVerb, "option": option, "module": e.Module, "key": e.Key, "kind": e.Kind, + "nodes": strings.Join(e.Nodes, " "), "ask": ask} +} + +// retireAsk is the ask composed from the retirement alone. +func (e Retiring) retireAsk(id string, at time.Time) oc.Ask { + nodes := strings.Join(e.Nodes, ", ") + if nodes == "" { + nodes = "no node" + } + what := "config value" + if e.Kind == KindSecret { + what = "secret" + } + a := oc.Ask{ID: id, Headline: fmt.Sprintf("Remove %s's %s %s?", e.Module, what, e.Key), + Explanation: fmt.Sprintf("No manifest reads %s's %s %s any more, and no node runs a build that reads it. Approved, it is removed on %s.", e.Module, what, e.Key, nodes), + Who: occ.Operator, Expires: occ.FormatTime(at.Add(ProposalLasts)), OnExpiry: "nothing is removed; mesh-config asks once more in a week", + About: "config.retire." + e.Module + "." + e.Key, + Details: fmt.Sprintf("Retiring since %s UTC. Read it with mesh-config's config_retiring.", e.Since.UTC().Format("15:04 on 2 Jan"))} + for _, o := range []struct{ id, label, does string }{ + {OptionApprove, "Approve", "the " + what + " is removed on " + nodes}, + {OptionDecline, "Decline", "nothing is removed; mesh-config asks no more until it is read again"}, + } { + binds, _ := occ.ActDigest(e.act(id, o.id)) + a.Options = append(a.Options, oc.Option{ID: o.id, Label: o.label, Does: o.does, Level: occ.Approve, Binds: binds}) + } + return a +} + +// ask makes the ask for a retirement and publishes it; the caller keeps the entry. +func (c *Config) ask(e *Retiring, again bool) error { + if c.Asks == nil { + return fmt.Errorf("the operator cannot be asked from here") + } + now := c.now() + id := retireAskID(*e, again) + a := e.retireAsk(id, now) + if err := occ.Check(a, now); err != nil { + return err + } + if err := c.Asks.Publish(oc.AcceptSubjectAsk(Module), a, a.ID); err != nil { + return err + } + e.Ask, e.State, e.AskedAt = id, RetireAsked, now + if e.Asked.IsZero() { + e.Asked = now + } + e.AskedAgain = e.AskedAgain || again + return nil +} + +// decideRetirements takes the router's word on each open retirement ask; the caller holds the lock. It also asks once +// more an ask unanswered for seven days, writes the finding of one asked twice, and finishes an approved removal. +func (c *Config) decideRetirements() []string { + held, err := c.readRetiring() + if err != nil || len(held) == 0 { + return nil + } + now := c.now() + var said []string + for _, key := range sortedKeys(held) { + e := held[key] + switch e.State { + case RetireAsked: + said = append(said, c.decideRetirement(&e, now)...) + case RetireUnanswered: + if !e.AskedAgain && now.Sub(e.Asked) >= HeldFinding { + if err := c.ask(&e, true); err != nil { + said = append(said, fmt.Sprintf("the operator is not asked again about %s's %s: %v", e.Module, e.Key, err)) + } else { + _ = c.Store.Put(RetiringState, key, e) + said = append(said, fmt.Sprintf("%s's %s: the operator is asked once more", e.Module, e.Key)) + } + } + case RetireApproved: + said = append(said, c.finishRetirement(&e)...) + } + } + return said +} + +func (c *Config) decideRetirement(e *Retiring, now time.Time) []string { + if c.Asks == nil { + return nil + } + key := retiringKey(e.Module, e.Key) + rec, err := c.Asks.Record(e.Ask) + if err != nil || rec == nil { + return nil + } + var r routerRecord + if json.Unmarshal(rec.Value, &r) != nil || r.State == "open" || r.Warrant == nil { + return nil + } + w := *r.Warrant + keep := func(state, outcome string) []string { + e.State, e.Outcome = state, outcome + if err := c.Store.Put(RetiringState, key, *e); err != nil { + return []string{fmt.Sprintf("%s's %s: %s, and that is not kept: %v", e.Module, e.Key, outcome, err)} + } + return []string{fmt.Sprintf("%s's %s: %s", e.Module, e.Key, outcome)} + } + if w.Asker != Module || w.Ask != e.Ask { + return keep(RetireUnanswered, "the router's record is for another ask; nothing is removed") + } + if w.Outcome != occ.OutcomeChosen { + out := keep(RetireUnanswered, "the ask "+string(w.Outcome)+"; nothing is removed") + if e.AskedAgain { + c.setFinding(retireUnanswered(*e, now)) + } + return out + } + option, err := occ.For(w, Module, e.retireAsk(e.Ask, e.AskedAt)) + if err == nil { + err = occ.Performs(option, e.act(e.Ask, option.ID)) + } + if err != nil { + c.logf("[mesh-config] REFUSED the answer to the retirement ask %s: %v", e.Ask, err) + return keep(RetireUnanswered, "the answer is refused and nothing is removed: "+err.Error()) + } + c.clearFinding(FindingRetireUnanswered, e.Module, e.Key) + e.Answered = occ.Says(w) + switch option.ID { + case OptionDecline: + return keep(RetireDeclined, "declined by the operator ("+occ.Says(w)+"); nothing is removed") + case OptionApprove: + out := keep(RetireApproved, "approved by the operator; being removed") + return append(out, c.finishRetirement(e)...) + } + return keep(RetireUnanswered, "the option "+option.ID+" is not one a retirement offers") +} + +// finishRetirement removes an approved retirement on each node it is not removed on yet: a value here, a secret through +// the vault. Each removal is said (`retired`); the entry goes when every node is done. The caller holds the lock. +func (c *Config) finishRetirement(e *Retiring) []string { + key := retiringKey(e.Module, e.Key) + var said, failed []string + for _, node := range e.Nodes { + if slices.Contains(e.Removed, node) { + continue + } + var err error + switch e.Kind { + case KindValue: + var removed bool + if removed, err = c.removeValue(*e, node); err == nil && !removed { + e.Removed = append(e.Removed, node) // nothing was set there + continue + } + default: + if c.Vault == nil { + err = fmt.Errorf("the seat mesh-vault is not reachable from here") + } else { + err = c.Vault.Retire(RetireRequest{Node: node, Module: e.Module, Name: e.Key, Ask: e.Ask}) + } + } + if err != nil { + failed = append(failed, node+": "+err.Error()) + continue + } + e.Removed = append(e.Removed, node) + if err := c.Store.Emit(EventRetired, RetiredEvent{Module: e.Module, Name: e.Key, Node: node, Ask: e.Ask}); err != nil { + c.logf("[mesh-config] retired for %s's %s on %s is not said: %v", e.Module, e.Key, node, err) + } + said = append(said, fmt.Sprintf("%s's %s is removed on %s", e.Module, e.Key, node)) + } + if len(failed) > 0 { + e.Outcome = "not removed everywhere yet: " + strings.Join(failed, "; ") + _ = c.Store.Put(RetiringState, key, *e) + return append(said, fmt.Sprintf("%s's %s: %s", e.Module, e.Key, e.Outcome)) + } + if err := c.Store.Delete(RetiringState, key); err != nil { + return append(said, fmt.Sprintf("the retirement %s is not dropped: %v", key, err)) + } + if _, err := c.reconcileLocked(); err != nil { + said = append(said, "resolving after the removal: "+err.Error()) + } + return said +} + +// removeValue deletes a module's own value on a node, recording it in the history, the hand-act log and config-changed. +func (c *Config) removeValue(e Retiring, node string) (bool, error) { + vk := moduleValueKey(node, e.Module, e.Key) + got, err := c.Store.Get(ValuesState, vk) + if err != nil { + return false, err + } + if got == nil { + return false, nil // nothing set there, or gone already + } + var was Value + _ = json.Unmarshal(got.Value, &was) + if err := c.Store.Delete(ValuesState, vk); err != nil { + return false, err + } + lvl := Level{Kind: LevelModule, Node: node, Module: e.Module} + ch := Change{Action: "retire", Level: &lvl, Key: e.Key, Was: was.Value, By: "the operator", Via: Module, + Why: "no manifest reads it any more (retiring), removed on the operator's Approve of ask " + e.Ask, At: c.now(), + Approved: e.Answered} + c.record(vk, ch) + if err := c.Store.HandAct(handActWhat(ch), ch.Why, "the operator's Approve of ask "+e.Ask); err != nil { + c.logf("[mesh-config] the hand-act log does not hold the retirement of %s: %v", vk, err) + } + return true, nil +} + +// ---- the tool ----------------------------------------------------------------------------------------------- + +// RetiringRow is one retirement as the tool says it. +type RetiringRow struct { + Module string `json:"module"` + Key string `json:"key"` + Kind string `json:"kind"` + Since time.Time `json:"since"` + State string `json:"state"` + Nodes []string `json:"nodes,omitempty"` + Ask string `json:"ask,omitempty"` + Outcome string `json:"outcome,omitempty"` + Answered string `json:"answered,omitempty"` +} + +// ConfigRetiring lists what is retiring (module narrows), or with action ask-again asks the operator again about one, +// or with cancel drops one (kept apart so it is not asked again until the value is read again). +func (c *Config) ConfigRetiring(action, module, key string) (any, error) { + c.mu.Lock() + defer c.mu.Unlock() + held, err := c.readRetiring() + if err != nil { + return nil, err + } + switch action { + case "", "list": + rows := []RetiringRow{} + for _, k := range sortedKeys(held) { + e := held[k] + if module != "" && e.Module != module { + continue + } + rows = append(rows, RetiringRow{Module: e.Module, Key: e.Key, Kind: e.Kind, Since: e.Since, State: e.State, + Nodes: e.Nodes, Ask: e.Ask, Outcome: e.Outcome, Answered: e.Answered}) + } + return map[string]any{"retiring": rows}, nil + case "ask-again", "cancel": + e, ok := held[retiringKey(module, key)] + if !ok { + return nil, fmt.Errorf("%s's %s is not retiring: config_retiring lists what is", module, key) + } + if action == "cancel" { + e.State, e.Outcome = RetireDeclined, "cancelled by hand" + if err := c.Store.Put(RetiringState, retiringKey(module, key), e); err != nil { + return nil, err + } + c.clearFinding(FindingRetireUnanswered, e.Module, e.Key) + if err := c.Store.HandAct("config_retiring cancel "+module+" "+key, "a retirement cancelled by hand", "config_retiring"); err != nil { + c.logf("[mesh-config] the hand-act log does not hold the cancel: %v", err) + } + return map[string]any{"said": module + "'s " + key + " is no longer asked about until it is read again"}, nil + } + if e.State == RetireDraining || e.State == RetireApproved { + return nil, fmt.Errorf("%s's %s is %s: there is nothing to ask again", module, key, e.State) + } + s, err := c.sources() + if err != nil { + return nil, err + } + if nodes := c.nodesReading(s, e); len(nodes) > 0 { + return nil, fmt.Errorf("%s still run a build that reads %s's %s", strings.Join(nodes, ", "), module, key) + } + if err := c.ask(&e, true); err != nil { + return nil, err + } + if err := c.Store.Put(RetiringState, retiringKey(module, key), e); err != nil { + return nil, err + } + return map[string]any{"said": "the operator is asked again", "ask": e.Ask}, nil + } + return nil, fmt.Errorf("config_retiring lists, or does ask-again or cancel; %q is none of them", action) +} diff --git a/modules/mesh-config/cmd/mesh-config/sources.go b/modules/mesh-config/cmd/mesh-config/sources.go index e8286bef4..fff2b2d73 100644 --- a/modules/mesh-config/cmd/mesh-config/sources.go +++ b/modules/mesh-config/cmd/mesh-config/sources.go @@ -4,23 +4,24 @@ import ( "encoding/json" "errors" "fmt" + assignerclient "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "sort" "strings" - - "git.novox.be/novox/mesh-sdk/go/delivery" ) // Reading the sources, writing the resolved values, and publishing the list of predefined keys. // sources reads everything a resolution reads, from the bus as it is now, from the source the switch names -// (takeover.go): which modules are placed where from the seat mesh-placement, and each placed module's declarations +// (takeover.go): which modules are placed where from the seat mesh-module-assigner, and each placed module's declarations // from its own manifests as built (issue 1953, ADR 0565 §3); the controller's records are no longer read. func (c *Config) sources() (Sources, error) { s := Sources{Declared: map[string]ModuleDeclarations{}, OnNode: map[string]ModuleDeclarations{}, - Predefined: map[string]Published{}, Values: map[string]Value{}} + Predefined: map[string]Published{}, Values: map[string]Value{}, Renames: map[string]Rename{}} var problems []string - if err := c.readPlacement(&s); err != nil { - return s, fmt.Errorf("the seat mesh-placement's placement cannot be read: %w", err) + if err := c.readAssignments(&s); err != nil { + return s, fmt.Errorf("the seat mesh-module-assigner's assignments cannot be read: %w", err) } if err := c.readDeclarations(&s); err != nil { return s, fmt.Errorf("the modules' declarations cannot be read: %w", err) @@ -51,6 +52,11 @@ func (c *Config) sources() (Sources, error) { if err := c.readPulls(&s); err != nil { return s, err } + if renames, err := c.readRenames(); err != nil { + return s, fmt.Errorf("the renames cannot be read: %w", err) + } else { + s.Renames = renames + } for _, p := range problems { c.logf("[mesh-config] a placed module's declarations are not read, so its values are not resolved (they "+ "stay as last resolved): %s", p) @@ -58,28 +64,27 @@ func (c *Config) sources() (Sources, error) { return s, nil } -// readPlacement is every module the seat mesh-placement places on a node (ADR 0567 §1, §9), from the state's keys in -// either form: a node key and its module keys, or the form before, `placement.` (delivery.PlacementsOf; -// research 072 row 37). By node in name order, each node's modules in the order its placement names them. -func (c *Config) readPlacement(s *Sources) error { +// readAssignments is every module the seat mesh-module-assigner places on a node (ADR 0567 §1, §9), from the state's keys, a +// node key and its module keys (assignerclient.AssignmentsOf; research 072 row 37). By node in name order, each node's modules in the order its assignments names them. +func (c *Config) readAssignments(s *Sources) error { keys := map[string][]byte{} - if err := c.each(PlacementRead, func(key string, raw []byte) error { + if err := c.each(AssignmentsRead, func(key string, raw []byte) error { keys[key] = raw return nil }); err != nil { return err } - placements, err := delivery.PlacementsOf(keys) + assignments, err := assignerclient.AssignmentsOf(keys) if err != nil { - return fmt.Errorf("the placement is not read whole: %w", err) + return fmt.Errorf("the assignments is not read whole: %w", err) } - nodes := make([]string, 0, len(placements)) - for node := range placements { + nodes := make([]string, 0, len(assignments)) + for node := range assignments { nodes = append(nodes, node) } sort.Strings(nodes) for _, node := range nodes { - for _, module := range placements[node].Assigned() { + for _, module := range assignerclient.Assigned(assignments[node]) { s.Assignments = append(s.Assignments, Assignment{Node: node, Module: module}) } } @@ -95,7 +100,7 @@ func (c *Config) readPlacement(s *Sources) error { func (c *Config) readDeclarations(s *Sources) error { wanted := map[string]string{} // . → the fingerprint its want names if err := c.each(WantsRead, func(key string, raw []byte) error { - node, module, err := delivery.ParseWantKey(key) + node, module, err := parseWantKey(key) if err != nil { return nil // a held key or a node's own want } @@ -183,7 +188,7 @@ func modulesOf(as []Assignment) []string { // trunkDeclarations is a module's declarations at the trunk: the manifest the indexer's `modules.` names. func (c *Config) trunkDeclarations(module string) (ModuleDeclarations, error) { - key, err := delivery.ModulesKey(module) + key, err := catalogclient.ModulesKey(module) if err != nil { return ModuleDeclarations{}, err } @@ -205,7 +210,7 @@ func (c *Config) trunkDeclarations(module string) (ModuleDeclarations, error) { // builtDeclarations is a module's declarations at a build: the manifest the builder's done key names. func (c *Config) builtDeclarations(module, fp string) (ModuleDeclarations, error) { - key, err := delivery.BuilderBuildKey(module, fp) + key, err := builds.BuilderBuildKey(module, fp) if err != nil { return ModuleDeclarations{}, err } @@ -234,7 +239,7 @@ func (c *Config) manifestDeclarations(module, digest string) (ModuleDeclarations if ok { return d, nil } - key, err := delivery.ManifestsKey(digest) + key, err := catalogclient.ManifestsKey(digest) if err != nil { return ModuleDeclarations{}, err } @@ -354,6 +359,8 @@ type Resolution struct { // did after the take-over. Declared []string `json:"declared,omitempty"` Candidates []string `json:"candidates,omitempty"` + // Renamed is what the renames did in this pass. + Renamed []string `json:"renamed,omitempty"` // SourceGone is said while the stored switch still names the controller, whose records are no longer read. SourceGone string `json:"source_gone,omitempty"` } @@ -364,6 +371,11 @@ type Resolution struct { func (c *Config) Reconcile() (Resolution, error) { c.mu.Lock() defer c.mu.Unlock() + return c.reconcileLocked() +} + +// reconcileLocked is Reconcile with the lock held. +func (c *Config) reconcileLocked() (Resolution, error) { var r Resolution published, err := c.publish() if err != nil { @@ -374,6 +386,8 @@ func (c *Config) Reconcile() (Resolution, error) { if err != nil { return r, err } + // The renames move their values first, so the same pass resolves them under the new names (renames.go). + r.Renamed = c.applyRenames(&s) moved, err := c.writeResolved(s) r.Moved = moved if len(moved) > 0 { diff --git a/modules/mesh-config/cmd/mesh-config/switch_tools.go b/modules/mesh-config/cmd/mesh-config/switch_tools.go index 51475b1f2..f4de6e738 100644 --- a/modules/mesh-config/cmd/mesh-config/switch_tools.go +++ b/modules/mesh-config/cmd/mesh-config/switch_tools.go @@ -11,12 +11,18 @@ import ( // The switch's tools. const ( - DeclaredTool = "config_declared" - ResyncTool = "config_resync" + DeclaredTool = "config_declared" + ResyncTool = "config_resync" + RenamesTool = "config_renames" + RenameAgainTool = "config_rename_again" + RenameCloseTool = "config_rename_close" + RetiringTool = "config_retiring" + RenamesVerb = "renames" ) -// SeatVerbs are the seat's verbs, as the manifest claims them. -var SeatVerbs = []string{Verb} +// SeatVerbs are the seat's verbs, as the manifest claims them: `config`, and the optional `renames` the checker asks +// (design 32: the open renames and the nodes holding each window open). +var SeatVerbs = []string{Verb, RenamesVerb} func switchTools(c *Config) []stdio.Tool { return ownSwitchTools(c) } @@ -35,5 +41,48 @@ func ownSwitchTools(c *Config) []stdio.Tool { Description: "Read every source again and write what differs: the published list of predefined keys, the " + "resolved values, declared, and the indexer's config candidates.", Run: func(map[string]any) (any, error) { return c.Resync() }}, + {Name: RenamesTool, + Description: "Each config value renamed (renamed-from): from which name to which, when, the nodes its value " + + "moved on, the nodes still holding the old name open (the rename window) and how each closed one closed. " + + "Narrowed by module.", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "module": map[string]any{"type": "string", "description": "one module"}}}, + Run: func(a map[string]any) (any, error) { + m, _ := a["module"].(string) + rows, err := c.Renames(strings.TrimSpace(m), false) + return map[string]any{"renames": rows}, err + }}, + {Name: RenameAgainTool, + Description: "Move a renamed value by hand, the way a resolution does: after a refused move (its finding says " + + "why) once the cause is put right, or for a value set under the old name meanwhile.", + Input: map[string]any{"type": "object", "required": []string{"module", "to"}, "properties": map[string]any{ + "module": map[string]any{"type": "string"}, "to": map[string]any{"type": "string", "description": "the new name"}}}, + Run: func(a map[string]any) (any, error) { + m, _ := a["module"].(string) + to, _ := a["to"].(string) + return c.RenameAgain(strings.TrimSpace(m), strings.TrimSpace(to)) + }}, + {Name: RenameCloseTool, + Description: "Drop a node from a rename's window by hand (a node retired for good), recorded as a hand act: " + + "the old name is no longer answered for it.", + Input: map[string]any{"type": "object", "required": []string{"module", "to", "node", "why"}, "properties": map[string]any{ + "module": map[string]any{"type": "string"}, "to": map[string]any{"type": "string", "description": "the new name"}, + "node": map[string]any{"type": "string"}, "why": map[string]any{"type": "string"}}}, + Run: func(a map[string]any) (any, error) { + str := func(k string) string { v, _ := a[k].(string); return strings.TrimSpace(v) } + return c.RenameClose(str("module"), str("to"), str("node"), str("why")) + }}, + {Name: RetiringTool, + Description: "The config values and secrets no manifest reads any more: since when, where each stands (draining " + + "while a node still runs a build that reads it, asked, unanswered, approved, declined) and its ask. With " + + "action ask-again, ask the operator once more about one; with cancel, drop one (it is asked about no more " + + "until it is read again).", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "action": map[string]any{"type": "string", "enum": []string{"list", "ask-again", "cancel"}}, + "module": map[string]any{"type": "string"}, "key": map[string]any{"type": "string"}}}, + Run: func(a map[string]any) (any, error) { + str := func(k string) string { v, _ := a[k].(string); return strings.TrimSpace(v) } + return c.ConfigRetiring(str("action"), str("module"), str("key")) + }}, } } diff --git a/modules/mesh-config/cmd/mesh-config/takeover.go b/modules/mesh-config/cmd/mesh-config/takeover.go index 26ca344bb..c616b929b 100644 --- a/modules/mesh-config/cmd/mesh-config/takeover.go +++ b/modules/mesh-config/cmd/mesh-config/takeover.go @@ -13,8 +13,9 @@ import ( "encoding/json" "errors" "fmt" - - "git.novox.be/novox/mesh-sdk/go/delivery" + catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "time" ) // The states and reads. @@ -23,8 +24,8 @@ const ( DeclaredState = "declared" // IndexerPredefinedRead and CandidatesRead are the indexer's states (ADR 0565 §2). - IndexerPredefinedRead = delivery.IndexerModule + "." + delivery.IndexerPredefinedState - CandidatesRead = delivery.IndexerModule + "." + delivery.IndexerConfigCandidatesState + IndexerPredefinedRead = indexerModule + "." + catalog.StatePredefined + CandidatesRead = indexerModule + "." + catalog.StateConfigCandidates ) // SourceIndexer is the one source of a pull request's config and of the predefined list: the indexer's. @@ -54,7 +55,7 @@ type DeclaredKept struct { // trunkKept is a module's declarations at the trunk with the manifest they come from. func (c *Config) trunkKept(module string) (DeclaredKept, error) { - key, err := delivery.ModulesKey(module) + key, err := catalogclient.ModulesKey(module) if err != nil { return DeclaredKept{}, err } @@ -119,16 +120,20 @@ func (c *Config) writeDeclared(s Sources) ([]string, error) { // indexerList is the indexer's predefined list as the mirror's list holds one: the keys, and the commit. func (c *Config) indexerList() (PredefinedList, bool, error) { - e, err := c.Store.Get(IndexerPredefinedRead, delivery.PredefinedListKey) + e, err := c.Store.Get(IndexerPredefinedRead, catalog.PredefinedByListKey()) if err != nil || e == nil { return PredefinedList{}, false, err } - var kept delivery.PredefinedKept + var kept catalog.PredefinedByListValue if err := json.Unmarshal(e.Value, &kept); err != nil { return PredefinedList{}, false, fmt.Errorf("the indexer's predefined list is not one: %w", err) } list := PredefinedList{Keys: map[string]PredefinedKey{}, Commit: kept.Commit} - if err := json.Unmarshal(kept.List, &list.Keys); err != nil { + listed, err := json.Marshal(kept.List) + if err == nil { + err = json.Unmarshal(listed, &list.Keys) + } + if err != nil { return PredefinedList{}, false, fmt.Errorf("the indexer's predefined list is not predefined.json: %w", err) } return list, true, nil @@ -138,11 +143,11 @@ func (c *Config) indexerList() (PredefinedList, bool, error) { func (c *Config) candidates() (map[string]PullDecl, error) { out := map[string]PullDecl{} err := c.each(CandidatesRead, func(key string, raw []byte) error { - var cand delivery.ConfigCandidate + var cand catalog.ConfigCandidatesByPullValue if err := json.Unmarshal(raw, &cand); err != nil || cand.Pull == "" { return fmt.Errorf("the indexer's config candidate %s is not one", key) } - p := PullDecl{Pull: cand.Pull, Head: cand.Head, Declares: map[string]map[string]Declaration{}, At: cand.At} + p := PullDecl{Pull: cand.Pull, Head: cand.Head, Declares: map[string]map[string]Declaration{}, At: timeOf(cand.At)} for module, keys := range cand.Declares { raw, _ := json.Marshal(keys) var d map[string]Declaration @@ -242,3 +247,11 @@ func (c *Config) Resync() (any, error) { } return out, nil } + +// timeOf is an optional time of a seat's value as a time, the zero time when it is absent. +func timeOf(t *time.Time) time.Time { + if t == nil { + return time.Time{} + } + return *t +} diff --git a/modules/mesh-config/cmd/mesh-config/takeover_test.go b/modules/mesh-config/cmd/mesh-config/takeover_test.go index f0c0bbad0..223488219 100644 --- a/modules/mesh-config/cmd/mesh-config/takeover_test.go +++ b/modules/mesh-config/cmd/mesh-config/takeover_test.go @@ -2,10 +2,9 @@ package main import ( "encoding/json" + catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "strings" "testing" - - "git.novox.be/novox/mesh-sdk/go/delivery" ) // The trunk's manifests aMesh declares. @@ -30,14 +29,14 @@ func anAgreeingMesh(t *testing.T) (*Config, *memStore, *standIn) { // indexerList is the indexer's `predefined.list` at a commit. func indexerList(t *testing.T, m *memStore, commit, list string) { t.Helper() - raw, _ := json.Marshal(delivery.PredefinedKept{V: 1, Commit: commit, List: json.RawMessage(list)}) - m.mirror(t, IndexerPredefinedRead, delivery.PredefinedListKey, string(raw)) + raw, _ := json.Marshal(catalog.PredefinedByListValue{V: 1, Commit: commit, List: json.RawMessage(list)}) + m.mirror(t, IndexerPredefinedRead, catalog.PredefinedByListKey(), string(raw)) } // candidate is the indexer's config candidate of a pull request. func candidate(t *testing.T, m *memStore, pull, head, declares string) { t.Helper() - key, err := delivery.ConfigCandidateKey(pull) + key, err := catalog.ConfigCandidatesByPullKey(pullKey(pull)), error(nil) if err != nil { t.Fatal(err) } diff --git a/modules/mesh-config/cmd/mesh-config/whole_integration_test.go b/modules/mesh-config/cmd/mesh-config/whole_integration_test.go new file mode 100644 index 000000000..a8d253d6e --- /dev/null +++ b/modules/mesh-config/cmd/mesh-config/whole_integration_test.go @@ -0,0 +1,841 @@ +//go:build integration + +package main + +// mesh-config whole against a real bus (design 32, C20 and C24): the built binary, played over its real stdin and stdout +// as the node's tool runtime plays it (tools/call; mesh/state.* answered from a real nats-server's key-value buckets, +// watches handed over as the runtime hands them, mesh/event delivering the events it subscribed to, mesh/ask answered +// as the status view, the indexer, the node-engine seat and mesh-hand-acts answer it, mesh/seat.* as the router does). +// +// A renamed value moves with no input, keeps its value, and both names answer until the node-engine reports the new +// build on `delivered`; the module restarted inside the window keeps the window and says `renamed` once; a refused move +// writes its finding. A value no manifest reads is not asked about while a node reads it, is asked once when none does, +// stays until the operator's Approve and is then removed, `retired` said. + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + assignerclient "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + engineclient "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + meshseat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "io" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-contracts/go/seats/meshconfig" + meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/process" + "git.novox.be/novox/mesh-sdk/go/testbus" + "git.novox.be/novox/mesh-sdk/go/views" +) + +type wire struct { + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` +} + +// player is the node's tool runtime, as far as mesh-config needs it, over a real bus. +type player struct { + t *testing.T + ctx context.Context + js jetstream.JetStream + router *standIn + mu sync.Mutex + in io.Writer + next int + pending map[string]chan wire + // haves is the status view's node_haves, by "."; readers the indexer's answer. + haves map[string]string + readers []ReaderRow + said []saidOnBus + subs []string + // owned is each delivery owner's state as the owner's tool answers it: its values and the revision of its last change. + owned map[string]*ownedState +} + +// ownedState is one state of an owner (the indexer's modules, the assigner's wants...), kept from its bucket. +type ownedState struct { + owner, state string + revision uint64 + values map[string]json.RawMessage +} + +var theCreated = time.Date(2026, 11, 4, 9, 0, 0, 0, time.UTC) + +// own follows the bucket of an owner's state, and says each change after the first listing on the owner's views family +// to the module (once it subscribed), as the owner does (ADR 0615 §4). +func (p *player) own(read string) { + p.t.Helper() + owner, state, _ := strings.Cut(read, ".") + kv, err := p.kv(read) + if err != nil { + p.t.Fatal(err) + } + w, err := kv.WatchAll(p.ctx) + if err != nil { + p.t.Fatal(err) + } + st := &ownedState{owner: owner, state: state, values: map[string]json.RawMessage{}} + p.owned[read] = st + listed := make(chan struct{}) + go func() { + current := true + for e := range w.Updates() { + if e == nil { + current = false + close(listed) + continue + } + p.mu.Lock() + deleted := e.Operation() != jetstream.KeyValuePut + if deleted { + delete(st.values, e.Key()) + } else { + st.values[e.Key()] = append(json.RawMessage(nil), e.Value()...) + } + if !current { + st.revision++ + ev := map[string]any{"v": 1, "owner": owner, "state": state, "key": e.Key(), "revision": st.revision, "created": theCreated} + if deleted { + ev["deleted"] = true + } else { + ev["value"] = json.RawMessage(e.Value()) + } + p.next++ + id := fmt.Sprintf("o-%d", p.next) + body, _ := json.Marshal(ev) + raw, _ := json.Marshal(map[string]any{"id": id, "jsonrpc": "2.0", "method": "mesh/event", "params": map[string]any{ + "envelope": map[string]any{"key": owner + ".views." + state, "node": "novox", "body": json.RawMessage(body)}}}) + _, _ = p.in.Write(append(raw, '\n')) + } + p.mu.Unlock() + } + }() + select { + case <-listed: + case <-time.After(10 * time.Second): + p.t.Fatalf("%s was not listed", read) + } +} + +// ownerAnswer is an owner's read tool asked with values: the state whole at its revision. +func (p *player) ownerAnswer(key string) (any, bool) { + for read, st := range p.owned { + if key == st.owner+"."+toolOf(st) { + _ = read + return map[string]any{"v": 1, "owner": st.owner, "state": st.state, "revision": st.revision, "created": theCreated, + "values": st.values, "at": time.Now()}, true + } + } + return nil, false +} + +func toolOf(st *ownedState) string { + prefix := "indexer_read_" + if st.owner == "mesh-delivery-assigner" { + prefix = "assigner_read_" + } + return prefix + strings.ReplaceAll(st.state, "-", "_") +} + +type saidOnBus struct { + Key string + Body json.RawMessage +} + +func (p *player) send(m map[string]any) { + raw, _ := json.Marshal(m) + p.mu.Lock() + defer p.mu.Unlock() + _, _ = p.in.Write(append(raw, '\n')) +} + +func (p *player) answer(id json.RawMessage, result any, err error) { + if err != nil { + p.send(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": err.Error()}}) + return + } + p.send(map[string]any{"id": id, "result": result}) +} + +func (p *player) serve(out io.Reader) { + scanner := bufio.NewScanner(out) + scanner.Buffer(make([]byte, 1<<20), 1<<24) + for scanner.Scan() { + var m wire + if json.Unmarshal(scanner.Bytes(), &m) != nil { + continue + } + if m.Method == "" { + var key string + _ = json.Unmarshal(m.ID, &key) + p.mu.Lock() + ch := p.pending[key] + delete(p.pending, key) + p.mu.Unlock() + if ch != nil { + ch <- m + } + continue + } + go p.handle(m) + } +} + +func (p *player) kv(state string) (jetstream.KeyValue, error) { + if owner, name, read := strings.Cut(state, "."); read { + return p.js.KeyValue(p.ctx, owner+"_"+name) + } + return p.js.KeyValue(p.ctx, Module+"_"+state) +} + +func (p *player) handle(m wire) { + var a struct { + State string `json:"state"` + Key string `json:"key"` + Value json.RawMessage `json:"value"` + Revision uint64 `json:"revision"` + Subject string `json:"subject"` + Body json.RawMessage `json:"body"` + Pattern string `json:"pattern"` + ID string `json:"id"` + Bucket string `json:"bucket"` + } + _ = json.Unmarshal(m.Params, &a) + kv, kvErr := p.kv(a.State) + switch m.Method { + case "mesh/state.get": + if kvErr != nil { + p.answer(m.ID, nil, kvErr) + return + } + e, err := kv.Get(p.ctx, a.Key) + if errors.Is(err, jetstream.ErrKeyNotFound) { + p.answer(m.ID, nil, nil) + return + } + if err != nil { + p.answer(m.ID, nil, err) + return + } + p.answer(m.ID, map[string]any{"key": e.Key(), "value": json.RawMessage(e.Value()), "revision": e.Revision()}, nil) + case "mesh/state.create", "mesh/state.update", "mesh/state.put": + if kvErr != nil { + p.answer(m.ID, nil, kvErr) + return + } + var rev uint64 + var err error + switch m.Method { + case "mesh/state.create": + rev, err = kv.Create(p.ctx, a.Key, a.Value) + case "mesh/state.update": + rev, err = kv.Update(p.ctx, a.Key, a.Value, a.Revision) + default: + rev, err = kv.Put(p.ctx, a.Key, a.Value) + } + if errors.Is(err, jetstream.ErrKeyExists) || errors.Is(err, jetstream.ErrKeyRevisionMismatch) { + p.send(map[string]any{"id": m.ID, "error": map[string]any{"code": -32010, "message": "changed: " + err.Error()}}) + return + } + p.answer(m.ID, map[string]any{"revision": rev}, err) + case "mesh/state.keys": + if kvErr != nil { + p.answer(m.ID, nil, kvErr) + return + } + keys, err := kv.Keys(p.ctx) + if errors.Is(err, jetstream.ErrNoKeysFound) { + keys, err = []string{}, nil + } + p.answer(m.ID, keys, err) + case "mesh/state.delete": + err := kvErr + if err == nil { + err = kv.Delete(p.ctx, a.Key) + } + p.answer(m.ID, map[string]any{}, err) + case "mesh/state.watch": + if kvErr != nil { + p.answer(m.ID, nil, kvErr) + return + } + w, err := kv.WatchAll(p.ctx) + if err != nil { + p.answer(m.ID, nil, err) + return + } + var watchID string + _ = json.Unmarshal(m.ID, &watchID) + current := true + for e := range w.Updates() { + if e == nil { + current = false + p.answer(m.ID, map[string]any{}, nil) + continue + } + op := "put" + if e.Operation() != jetstream.KeyValuePut { + op = "delete" + } + p.mu.Lock() + p.next++ + id := fmt.Sprintf("w-%d", p.next) + p.mu.Unlock() + p.send(map[string]any{"id": id, "method": "mesh/state", "params": map[string]any{"watch": watchID, + "change": map[string]any{"state": a.State, "key": e.Key(), "op": op, "value": json.RawMessage(nullIfEmpty(e.Value())), + "revision": e.Revision(), "current": current}}}) + } + case "mesh/log.append": + p.answer(m.ID, map[string]any{"sequence": 1}, nil) + case "mesh/log.read": + p.answer(m.ID, map[string]any{"entries": []any{}, "next": 0, "last": 0}, nil) + case "mesh/publish": + var e struct { + Key string `json:"key"` + Body json.RawMessage `json:"body"` + } + _ = json.Unmarshal(m.Params, &e) + p.mu.Lock() + p.said = append(p.said, saidOnBus{e.Key, e.Body}) + p.mu.Unlock() + p.answer(m.ID, map[string]any{}, nil) + case "mesh/subscribe": + p.mu.Lock() + p.subs = append(p.subs, a.Pattern) + p.mu.Unlock() + p.answer(m.ID, map[string]any{}, nil) + case "mesh/seat.publish": + var ask oc.Ask + if err := json.Unmarshal(a.Body, &ask); err != nil { + p.answer(m.ID, nil, err) + return + } + p.answer(m.ID, map[string]any{"sequence": 1}, p.router.Publish(a.Subject, ask, a.ID)) + case "mesh/seat.record": + e, err := p.router.Record(a.Key) + if e == nil { + p.answer(m.ID, nil, err) + return + } + p.answer(m.ID, e, err) + case "mesh/seat.kinds": + p.answer(m.ID, p.router.kinds, nil) + case "mesh/ask": + var q struct { + Key string `json:"key"` + Body json.RawMessage `json:"body"` + } + _ = json.Unmarshal(m.Params, &q) + p.answer(m.ID, p.asked(q.Key, q.Body), nil) + default: + p.answer(m.ID, nil, errors.New("nats: no responders available for request")) + } +} + +// asked answers a tool or a seat verb mesh-config asks, as its holder does. +func (p *player) asked(key string, body json.RawMessage) any { + p.mu.Lock() + defer p.mu.Unlock() + if a, ok := p.ownerAnswer(key); ok { + return a + } + switch { + case key == HavesAsk: + var q struct{ Node, Module string } + _ = json.Unmarshal(body, &q) + hk, _ := engineclient.HaveKey(q.Node, q.Module) + keys := map[string]any{} + if fp, ok := p.haves[q.Node+"."+q.Module]; ok { + keys[hk] = nodeengine.HaveByNodeModuleValue{V: 1, Fp: fp, Phase: "running", Health: "ok"} + } + return map[string]any{"keys": keys} + case strings.HasPrefix(key, ManifestReadsAsk): + return map[string]any{} + case key == ConfigReadersAsk: + return map[string]any{"readers": p.readers} + case key == "seat:mesh-hand-acts.hand-act" || key == "seat:mesh-hand-acts.warranted": + return map[string]any{"said": "recorded"} + } + return map[string]any{"error": "no responders available for " + key} +} + +func nullIfEmpty(b []byte) []byte { + if len(b) == 0 { + return []byte("null") + } + return b +} + +func (p *player) call(name string, args map[string]any) (json.RawMessage, error) { + p.mu.Lock() + p.next++ + id := fmt.Sprintf("c-%d", p.next) + ch := make(chan wire, 1) + p.pending[id] = ch + p.mu.Unlock() + p.send(map[string]any{"id": id, "method": "tools/call", "params": map[string]any{"name": name, "arguments": args, + "_meta": map[string]any{"mesh/caller": "person.jochen"}}}) + select { + case m := <-ch: + if m.Error != nil { + return nil, errors.New(m.Error.Message) + } + var r struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + IsError bool `json:"isError"` + } + if err := json.Unmarshal(m.Result, &r); err != nil || len(r.Content) != 1 { + return nil, fmt.Errorf("%s answered %s", name, m.Result) + } + if r.IsError { + return nil, errors.New(r.Content[0].Text) + } + return json.RawMessage(r.Content[0].Text), nil + case <-time.After(60 * time.Second): + return nil, fmt.Errorf("%s did not answer in 60s", name) + } +} + +// event hands the module one event it subscribed to, as the runtime does, and waits for its answer. +func (p *player) event(key string, body any) { + p.t.Helper() + raw, _ := json.Marshal(body) + p.mu.Lock() + p.next++ + id := fmt.Sprintf("e-%d", p.next) + ch := make(chan wire, 1) + p.pending[id] = ch + p.mu.Unlock() + p.send(map[string]any{"id": id, "method": "mesh/event", "params": map[string]any{"envelope": map[string]any{"key": key, "node": "novox", + "body": json.RawMessage(raw)}}}) + select { + case m := <-ch: + if m.Error != nil { + p.t.Fatalf("%s was not taken: %s", key, m.Error.Message) + } + case <-time.After(30 * time.Second): + p.t.Fatalf("%s was not answered in 30s", key) + } +} + +// conforms decodes what the module published under key strictly into the seat definition's type (a field the type does +// not know is a fault here, though a reader drops it) and checks the subject the runtime publishes it on is the one the +// definition names: CAT-V4, the definition's fixtures matched by a real event. +func (p *player) conforms(key string, into any, subject func(holder string) string) { + p.t.Helper() + p.mu.Lock() + defer p.mu.Unlock() + found := false + for _, s := range p.said { + if s.Key != key { + continue + } + found = true + dec := json.NewDecoder(strings.NewReader(string(s.Body))) + dec.DisallowUnknownFields() + if err := dec.Decode(into); err != nil { + p.t.Errorf("the published %s %s does not match the seat's definition: %v", key, s.Body, err) + } + // The runtime publishes an event its manifest's own stream carries on that stream (mesh.stream), any other on + // EVENTS (mesh.mod). + got := "mesh.mod." + Module + ".event." + s.Key + if streamCarries(p.t, s.Key) { + got = process.ModuleStreamSubject(Module, s.Key) + } + if want := subject(Module); got != want { + p.t.Errorf("%s is published on %s, the definition names %s", key, got, want) + } + } + if !found { + p.t.Errorf("%s was never published", key) + } +} + +func (p *player) publishedCount(key string) int { + p.mu.Lock() + defer p.mu.Unlock() + n := 0 + for _, s := range p.said { + if s.Key == key { + n++ + } + } + return n +} + +func (p *player) put(state, key string, v any) { + p.t.Helper() + kv, err := p.kv(state) + if err != nil { + p.t.Fatal(err) + } + raw, _ := json.Marshal(v) + if _, err := kv.Put(p.ctx, key, raw); err != nil { + p.t.Fatal(err) + } +} + +func (p *player) get(state, key string) ([]byte, bool) { + kv, err := p.kv(state) + if err != nil { + return nil, false + } + e, err := kv.Get(p.ctx, key) + if err != nil { + return nil, false + } + return e.Value(), true +} + +// hearsTheViews waits until the module subscribed to every owner's views family it follows: a change said before that +// would reach it only as a gap. Waited on, never slept for. +func (p *player) hearsTheViews() { + p.t.Helper() + eventually(p.t, "the module subscribed to every views family it follows", func() bool { + p.mu.Lock() + defer p.mu.Unlock() + for _, f := range followed { + if !slices.Contains(p.subs, f.Owner+"."+views.EventName(f.State)) { + return false + } + } + return true + }) +} + +// viewsReady waits until every first view of the module is answered (config_first_views). +func (p *player) viewsReady() { + p.t.Helper() + eventually(p.t, "every first view is answered", func() bool { + raw, err := p.call(FirstViewsTool, nil) + if err != nil { + return false + } + var a struct { + Views []struct { + Revision uint64 `json:"revision"` + Answered time.Time `json:"Answered"` + } `json:"first-views"` + } + if json.Unmarshal(raw, &a) != nil || len(a.Views) != len(followed) { + return false + } + for _, v := range a.Views { + if v.Answered.IsZero() { + return false + } + } + return true + }) +} + +func eventually(t *testing.T, what string, ok func() bool) { + t.Helper() + for deadline := time.Now().Add(30 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { + if ok() { + return + } + } + t.Fatalf("never: %s", what) +} + +// manifestIn writes a module's manifest into the indexer's buckets as the indexer writes it, and names it as the trunk's. +func (p *player) manifestIn(module, raw string) string { + p.t.Helper() + digest := catalogclient.ManifestDigest([]byte(raw)) + value, err := catalogclient.EncodeManifest(catalog.ManifestsByDigestValue{Module: module, Manifest: raw}) + if err != nil { + p.t.Fatal(err) + } + p.put("mesh-delivery-indexer.manifests", digest, json.RawMessage(value)) + p.put("mesh-delivery-indexer.modules", module, map[string]any{"v": 1, "manifest": digest, "repository": "novox/" + module, "commit": "c1"}) + return digest +} + +// buildIn writes a build of a module with a manifest, and the want that names it on a node. +func (p *player) buildIn(node, module, fp, raw string) { + p.t.Helper() + digest := catalogclient.ManifestDigest([]byte(raw)) + value, _ := catalogclient.EncodeManifest(catalog.ManifestsByDigestValue{Module: module, Manifest: raw}) + p.put("mesh-delivery-indexer.manifests", digest, json.RawMessage(value)) + bk, _ := builds.BuilderBuildKey(module, fp) + p.put("mesh-delivery-builder.builds", bk, map[string]any{"v": 1, "state": "done", "manifest": digest}) + p.put("mesh-delivery-assigner.wants", "want."+node+"."+module, map[string]any{"v": 1, "fp": fp, "self": true}) +} + +func (p *player) placed(node string, modules ...string) { + p.t.Helper() + pl := meshseat.Assignments{V: 1, Node: node} + for _, m := range modules { + pl.Modules = append(pl.Modules, meshseat.PlacedModule{Module: m}) + } + // In the order the seat's holder writes them (module keys before the node key that names their digests), so a reader + // never holds a node key whose module keys have not arrived. + writes, err := assignerclient.NewKeys().Writes(pl.Node, &pl) + if err != nil { + p.t.Fatal(err) + } + for _, w := range writes { + p.put("mesh-delivery-assigner.assignments", w.Key, json.RawMessage(w.Value)) + } +} + +func resolvedValue(p *player, node, module, key string) (string, bool) { + raw, ok := p.get(ResolvedState, ResolvedKey(node, module, key)) + if !ok { + return "", false + } + var r Resolved + _ = json.Unmarshal(raw, &r) + return string(r.Value), true +} + +// start runs the built binary on the bus and serves it as the runtime. +func start(t *testing.T, bin string, js jetstream.JetStream, router *standIn, haves map[string]string, readers []ReaderRow) *player { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + cmd := exec.CommandContext(ctx, bin) + cmd.Env = append(os.Environ(), "MESH_NODE=novox", "MESH_MODULE="+Module) + stdin, _ := cmd.StdinPipe() + stdout, _ := cmd.StdoutPipe() + cmd.Stderr = os.Stderr + p := &player{t: t, ctx: ctx, js: js, router: router, in: stdin, pending: map[string]chan wire{}, haves: haves, readers: readers, + owned: map[string]*ownedState{}} + for _, f := range followed { + p.own(f.Read) + } + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = stdin.Close(); cancel(); _ = cmd.Wait() }) + go p.serve(stdout) + t.Cleanup(cancel) + return p +} + +// 1966/C9 (design 32, C20 and C24): the module whole on a real bus. +func TestConfigWholeRenamesAndRetiresAgainstTheBus(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + bin := filepath.Join(t.TempDir(), Module) + build := exec.Command("go", "build", "-o", bin, ".") + build.Stderr = os.Stderr + if err := build.Run(); err != nil { + t.Fatalf("mesh-config does not build: %v", err) + } + nc, err := nats.Connect(testbus.URL(t)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(nc.Close) + js, err := jetstream.New(nc) + if err != nil { + t.Fatal(err) + } + buckets := []string{"mesh-delivery-assigner_assignments", "mesh-delivery-assigner_wants", "mesh-delivery-builder_builds", + "mesh-delivery-indexer_modules", "mesh-delivery-indexer_manifests", "mesh-delivery-indexer_predefined", + "mesh-delivery-indexer_config-candidates"} + for _, s := range []string{ValuesState, ResolvedState, PredefinedState, ProposalsState, PullsState, KeptState, DeclaredState, + RenamesState, RetiringState, FindingsState} { + buckets = append(buckets, Module+"_"+s) + } + for _, b := range buckets { + if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: b, History: 2}); err != nil { + t.Fatal(err) + } + } + router := newStandIn() + haves := map[string]string{"g14.player": fp1, "novox.player": fp1, "g14.radio": fp1} + p := start(t, bin, js, router, haves, nil) + p.viewsReady() + + // The mesh: player and radio on g14, player also on novox, all on their first build; dock declares both names. + const oldRadio = `{"module":"radio","config":{"band":{"type":"number","default":1}}}` + const newRadio = `{"module":"radio","config":{"gain":{"type":"number","default":1}}}` + const dock = `{"module":"dock","config":{"a":{"type":"number","default":1},"b":{"type":"number","default":1,"renamed-from":"a"}}}` + p.manifestIn("player", oldPlayer) + p.manifestIn("radio", oldRadio) + p.buildIn("g14", "player", fp1, oldPlayer) + p.buildIn("novox", "player", fp1, oldPlayer) + p.buildIn("g14", "radio", fp1, oldRadio) + p.placed("g14", "player", "radio") + p.placed("novox", "player") + if _, err := p.call(ResyncTool, nil); err != nil { + t.Fatal(err) + } + p.hearsTheViews() + set := func(module, key string, value any) { + t.Helper() + if _, err := p.call(Seat+"."+Verb, map[string]any{"action": "set", "module": module, "node": "g14", "key": key, + "value": value, "why": "the operator asked for it"}); err != nil { + t.Fatal(err) + } + } + set("player", "volume", 8) + set("radio", "band", 3) + + // C20: the new manifest renames volume to level; the value moves, both names answer. + p.manifestIn("player", newPlayer) + eventually(t, "the value moves to the new name", func() bool { + raw, ok := p.get(ValuesState, "module.g14.player.level") + return ok && strings.Contains(string(raw), `"value":8`) + }) + if _, ok := p.get(ValuesState, "module.g14.player.volume"); ok { + t.Error("the old name still holds a value") + } + eventually(t, "both names answer on g14", func() bool { + a, okA := resolvedValue(p, "g14", "player", "level") + b, okB := resolvedValue(p, "g14", "player", "volume") + return okA && okB && a == "8" && b == "8" + }) + eventually(t, "renamed is said", func() bool { return p.publishedCount(EventRenamed) == 1 }) + + p.conforms(EventRenamed, &meshconfig.RenamedHolderEvent{}, meshconfig.HolderSubjectRenamed) + + // A refused move writes its finding: dock declares both names. + p.manifestIn("dock", dock) + p.buildIn("g14", "dock", fp1, dock) + p.placed("g14", "player", "radio", "dock") + eventually(t, "the refused rename's finding", func() bool { + keys, _ := (func() ([]string, error) { kv, _ := p.kv(FindingsState); return kv.Keys(p.ctx) })() + for _, k := range keys { + raw, _ := p.get(FindingsState, k) + if f, err := meshconditions.DecodeFindingsByKeyValue(raw); err == nil && f.Kind == FindingRenameRefused && strings.Contains(f.Summary, "dock") { + return true + } + } + return false + }) + + // The module restarted inside the window keeps it, and says renamed no more. + said := p.publishedCount(EventRenamed) + p2 := start(t, bin, js, router, haves, nil) + p2.viewsReady() + if _, err := p2.call(ResyncTool, nil); err != nil { + t.Fatal(err) + } + eventually(t, "after the restart the old name answers 8", func() bool { + v, ok := resolvedValue(p2, "g14", "player", "volume") + return ok && v == "8" + }) + if n := p2.publishedCount(EventRenamed); n != 0 || said != 1 { + t.Errorf("renamed was said %d times before the restart and %d after", said, n) + } + raw, _ := p2.get(RenamesState, "player.level") + var rn Rename + if json.Unmarshal(raw, &rn) != nil || strings.Join(rn.Open(), ",") != "g14,novox" { + t.Fatalf("the window after the restart: %s", raw) + } + + // The seat's verb answers the open window in the definition's shape (CAT-C10). + raw, err = p2.call(Seat+"."+RenamesVerb, map[string]any{"module": "player"}) + if err != nil { + t.Fatal(err) + } + var open meshconfig.RenamesAnswer + dec := json.NewDecoder(strings.NewReader(string(raw))) + dec.DisallowUnknownFields() + if err := dec.Decode(&open); err != nil || len(open.Renames) != 1 || open.Renames[0].To != "level" || + strings.Join(open.Renames[0].Open, ",") != "g14,novox" { + t.Fatalf("renames answered %s (%v), want player's level open on g14 and novox", raw, err) + } + + // g14 applies the build that reads the new name; the delivery says so; its window closes, novox's stays. + p2.buildIn("g14", "player", fp2, newPlayer) + p2.mu.Lock() + p2.haves["g14.player"] = fp2 + p2.mu.Unlock() + p2.event("mesh-delivery-view.delivered", map[string]any{"revision": "r1"}) + eventually(t, "g14's window closes and the old name goes", func() bool { + _, old := resolvedValue(p2, "g14", "player", "volume") + v, still := resolvedValue(p2, "novox", "player", "volume") + return !old && still && v == "5" + }) + + // C24: radio's manifest drops band; g14 still runs the build that reads it: no ask. Then it runs the next: one ask. + p2.manifestIn("radio", newRadio) + p2.mu.Lock() + p2.readers = []ReaderRow{{Module: "radio", Key: "band", Kind: KindValue, Readers: []string{}}} + p2.mu.Unlock() + p2.event("mesh-catalog.manifest-accepted", map[string]any{"module": "radio"}) + eventually(t, "the retirement waits on g14", func() bool { + raw, ok := p2.get(RetiringState, "radio.band") + return ok && strings.Contains(string(raw), RetireDraining) + }) + if len(router.asks) != 0 || p2.publishedCount(EventRetiring) != 0 { + t.Fatalf("asked %d times, retiring said %d times while g14 reads the value", len(router.asks), p2.publishedCount(EventRetiring)) + } + p2.buildIn("g14", "radio", fp2, newRadio) + p2.mu.Lock() + p2.haves["g14.radio"] = fp2 + p2.mu.Unlock() + p2.event("mesh-delivery-view.delivered", map[string]any{"revision": "r2"}) + eventually(t, "the operator is asked once", func() bool { + router.mu.Lock() + defer router.mu.Unlock() + return len(router.asks) == 1 && p2.publishedCount(EventRetiring) == 1 + }) + if _, ok := p2.get(ValuesState, "module.g14.radio.band"); !ok { + t.Fatal("the value was removed before the operator answered") + } + var id string + router.mu.Lock() + for k := range router.asks { + id = k + } + router.mu.Unlock() + w := router.choose(t, id, OptionApprove, nil) + p2.event(oc.Seat+".decided."+Module, w) + eventually(t, "the approved value is removed and retired is said", func() bool { + _, still := p2.get(ValuesState, "module.g14.radio.band") + return !still && p2.publishedCount(EventRetired) == 1 + }) + p2.conforms(EventRetiring, &meshconfig.RetiringHolderEvent{}, meshconfig.HolderSubjectRetiring) + p2.conforms(EventRetired, &meshconfig.RetiredHolderEvent{}, meshconfig.HolderSubjectRetired) + eventually(t, "the retirement is dropped", func() bool { _, ok := p2.get(RetiringState, "radio.band"); return !ok }) +} + +// streamCarries says whether the module's manifest names event among its own streams' events. +func streamCarries(t *testing.T, event string) bool { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + m, err := catalogue.Decode(raw) + if err != nil { + t.Fatal(err) + } + for _, st := range m.Streams { + if slices.Contains(st.Events, event) { + return true + } + } + return false +} diff --git a/modules/mesh-config/go.mod b/modules/mesh-config/go.mod index 95e35ffb4..fc6a03485 100644 --- a/modules/mesh-config/go.mod +++ b/modules/mesh-config/go.mod @@ -2,13 +2,44 @@ module mesh-config go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 +require ( + git.novox.be/novox/mesh-contracts/go/clients/meshconfigclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/go/seats/meshconfig v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 + github.com/nats-io/nats.go v1.54.0 +) require ( - github.com/klauspost/compress v1.20.0 // indirect - github.com/nats-io/nats.go v1.54.0 // indirect + github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/klauspost/compress v1.20.1 // indirect + github.com/minio/highwayhash v1.0.4 // indirect + github.com/nats-io/jwt/v2 v2.8.2 // indirect + github.com/nats-io/nats-server/v2 v2.11.17 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect golang.org/x/crypto v0.57.0 // indirect golang.org/x/sys v0.48.0 // indirect + golang.org/x/time v0.16.0 // indirect +) + +require ( + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 // indirect ) diff --git a/modules/mesh-config/go.sum b/modules/mesh-config/go.sum index 02d6daae7..8ca93dfd0 100644 --- a/modules/mesh-config/go.sum +++ b/modules/mesh-config/go.sum @@ -1,7 +1,15 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= +github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc= +github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc= +github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= +github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= @@ -10,5 +18,8 @@ github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= diff --git a/modules/mesh-config/module.json b/modules/mesh-config/module.json index d62def76f..af934ab71 100644 --- a/modules/mesh-config/module.json +++ b/modules/mesh-config/module.json @@ -1,13 +1,17 @@ { "module": "mesh-config", "version": "1", + "packages": { + "builds": "0.1" + }, "runs-as": "mesh-config", "claims": [ { "name": "mesh-config", "scope": "mesh", "serves": [ - "config" + "config", + "renames" ] } ], @@ -20,25 +24,44 @@ "consumes": [ "gitea.pull.merged", "gitea.pull.closed", - "gitea.forge.pull-request.closed" + "gitea.forge.pull-request.closed", + "mesh-delivery-view.delivered", + "node-engine.manifest-reads-changed", + "mesh-catalog.manifest-accepted", + "mesh-delivery-indexer.views.modules", + "mesh-delivery-indexer.views.manifests", + "mesh-delivery-indexer.views.predefined", + "mesh-delivery-indexer.views.config-candidates", + "mesh-delivery-assigner.views.assignments", + "mesh-delivery-assigner.views.wants" ], "invokes": [ "seat:mesh-hand-acts.hand-act", "seat:mesh-hand-acts.warranted", - "gitea.gitea_get_pull_request" + "gitea.gitea_get_pull_request", + "mesh-delivery-view.node_haves", + "seat:node-engine.manifest-reads", + "mesh-delivery-indexer.indexer_config_readers", + "seat:mesh-vault.retire", + "mesh-delivery-indexer.indexer_read_modules", + "mesh-delivery-indexer.indexer_read_manifests", + "mesh-delivery-indexer.indexer_read_predefined", + "mesh-delivery-indexer.indexer_read_config_candidates", + "mesh-delivery-assigner.assigner_read_assignments", + "mesh-delivery-assigner.assigner_read_wants" ], "reads": [ - "mesh-delivery-assigner.placement", - "mesh-delivery-assigner.wants", - "mesh-delivery-builder.builds", - "mesh-delivery-indexer.modules", - "mesh-delivery-indexer.manifests", - "mesh-delivery-indexer.predefined", - "mesh-delivery-indexer.config-candidates" + "mesh-delivery-builder.builds" ], "tools": [ "config_declared", - "config_resync" + "config_resync", + "config_renames", + "config_rename_again", + "config_rename_close", + "config_retiring", + "config_first_views", + "config_first_view_again" ], "state": [ "values", @@ -47,7 +70,10 @@ "proposals", "pulls", "kept", - "declared" + "declared", + "renames", + "retiring", + "findings" ], "logs": [ "history" @@ -87,5 +113,16 @@ ] } ] - } + }, + "streams": [ + { + "name": "config-events", + "events": [ + "renamed", + "retiring", + "retired" + ], + "retention": "history" + } + ] } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr548_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr548_test.go index 862b274fc..fdcaacc0a 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr548_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr548_test.go @@ -6,8 +6,6 @@ import ( "sync" "testing" "time" - - sdk "git.novox.be/novox/mesh-sdk/go/delivery" ) // One check per delivery (novox/hq ADR 0548), the gate's part: nothing merges into a repository while a check of other heads @@ -30,27 +28,27 @@ func approvedLone(w *world) string { // memTurns is the bucket turns in memory; it logs each write with the merges the fake bridge had made by then. type memTurns struct { mu sync.Mutex - m map[string]sdk.Turn + m map[string]Turn log []string merges func() int } func newMemTurns(w *world) *memTurns { - return &memTurns{m: map[string]sdk.Turn{}, merges: func() int { return len(w.forge.merges) }} + return &memTurns{m: map[string]Turn{}, merges: func() int { return len(w.forge.merges) }} } -func (f *memTurns) All() (map[string]sdk.Turn, error) { +func (f *memTurns) All() (map[string]Turn, error) { f.mu.Lock() defer f.mu.Unlock() - out := map[string]sdk.Turn{} + out := map[string]Turn{} for k, v := range f.m { out[k] = v } return out, nil } -func (f *memTurns) Put(t sdk.Turn) error { - if _, err := sdk.EncodeTurn(t); err != nil { +func (f *memTurns) Put(t Turn) error { + if _, err := EncodeTurn(t); err != nil { return err } f.mu.Lock() diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go index 77d9805f2..cbd99faf1 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go @@ -9,9 +9,10 @@ import ( "strings" "time" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" + mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/asks" - sdk "git.novox.be/novox/mesh-sdk/go/delivery" ) // A merge into main waits for the operator's Approve (novox/hq ADR 0398 §3, issue 948, ADR 0421). @@ -45,17 +46,17 @@ const ( ) // AskLasts is how long the operator has to answer: a day less a margin, under the router's bound for an approval. -const AskLasts = asks.ApproveLasts - 10*time.Minute +const AskLasts = occ.ApproveLasts - 10*time.Minute // Operator is how this owner asks the operator: through the keys of its bucket asks and the teller's answers. kvOperator in // the running module. type Operator interface { - // Ask writes the ask under its key (sdk.MergeAskKey); it writes nothing when that key holds the same ask already. - Ask(key string, a sdk.Ask) error + // Ask writes the ask under its key (mdc.MergeAskKey); it writes nothing when that key holds the same ask already. + Ask(key string, a Ask) error // Cancel takes the ask at a key away, which the teller reads as the ask withdrawn. Cancel(key string) error // Where is the teller's answer to the ask at a key, or nil when there is none. - Where(key string) (*sdk.Answer, error) + Where(key string) (*Answer, error) } // askGroup is the name a delivery's ask and merge are kept under: its group's, or, for a lone pull request, a group of one @@ -81,8 +82,8 @@ func headsString(heads map[string]string) string { } // mergeAct is what an answer binds: this ask, the answer, and the heads it covers. -func mergeAct(ask, option string, heads map[string]string) asks.Act { - return asks.Act{"verb": Seat + ".merge", "ask": ask, "option": option, "heads": headsString(heads)} +func mergeAct(ask, option string, heads map[string]string) occ.Act { + return occ.Act{"verb": Seat + ".merge", "ask": ask, "option": option, "heads": headsString(heads)} } var unshownPath = regexp.MustCompile(`[/\\@~$]|\d+\.\d+\.\d+|[A-Za-z0-9+/=_]{24,}|(?i)\b[a-z0-9-]+\.[a-z]{2,12}\b`) @@ -183,9 +184,9 @@ func mergeHeadline(c checkSaid) string { // check is the state of the check at the heads it covers (checkAt): the ask says "passed its check" only when it did, and // otherwise that Approve merges before the check ends, or although it failed (issue 1733). func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery, heads map[string]string, check checkSaid, - now time.Time) asks.Ask { - var a asks.Ask - a.ID, a.Who, a.Expires = id, asks.Operator, now.Add(AskLasts) + now time.Time) oc.Ask { + var a oc.Ask + a.ID, a.Who, a.Expires = id, occ.Operator, occ.FormatTime(now.Add(AskLasts)) a.OnExpiry = "nothing is merged; the ask is made again" a.About = "delivery." + phone(repoName(d.Repository), 40) + "." + strconv.Itoa(d.Number) a.About = strings.ReplaceAll(a.About, " ", "-") @@ -203,24 +204,24 @@ func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery, pullSaid(m), m.Title, shortOf(m.Commit, 12), issuesSaid([]*Delivery{m}), words, touches)) } if group == "" { - a.Headline = phone(mergeHeadline(check)+pullSaid(d)+" "+d.Title, asks.HeadlineLength) + a.Headline = phone(mergeHeadline(check)+pullSaid(d)+" "+d.Title, occ.HeadlineLength) summary := fmt.Sprintf("%s %s%s. Approve merges it into main at %s%s; Decline leaves it unmerged.", pullSaid(d), d.Title, checkClause(check, " "), shortOf(d.Commit, 12), approveCaveat(check)) if unreviewed != "" { summary = strings.ToUpper(unreviewed[:1]) + unreviewed[1:] + ". " + summary } a.Explanation = strings.Join([]string{ - phone(summary, asks.SummaryLength), - phone("Issues: "+issuesSaid(members), asks.PointLength), - phone("Review: "+reviewers[0], asks.PointLength), - phone("Touches: "+reaches[0], asks.PointLength), + phone(summary, occ.SummaryLength), + phone("Issues: "+issuesSaid(members), occ.PointLength), + phone("Review: "+reviewers[0], occ.PointLength), + phone("Touches: "+reaches[0], occ.PointLength), }, "\n") } else { var names []string for _, m := range members { names = append(names, pullSaid(m)) } - a.Headline = phone(mergeHeadline(check)+"group "+group+" ("+strings.Join(names, ", ")+")", asks.HeadlineLength) + a.Headline = phone(mergeHeadline(check)+"group "+group+" ("+strings.Join(names, ", ")+")", occ.HeadlineLength) caveat := approveCaveat(check) if caveat != "" { caveat = "," + caveat @@ -231,10 +232,10 @@ func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery, summary = strings.ToUpper(unreviewed[:1]) + unreviewed[1:] + ". " + summary } a.Explanation = strings.Join([]string{ - phone(summary, asks.SummaryLength), - phone("Issues: "+issuesSaid(members), asks.PointLength), - phone("Review: "+strings.Join(reviewers, "; "), asks.PointLength), - phone("Touches: "+strings.Join(reaches, "; "), asks.PointLength), + phone(summary, occ.SummaryLength), + phone("Issues: "+issuesSaid(members), occ.PointLength), + phone("Review: "+strings.Join(reviewers, "; "), occ.PointLength), + phone("Touches: "+strings.Join(reaches, "; "), occ.PointLength), }, "\n") } a.Details = phone(strings.Join(details, "\n"), 1500) @@ -251,8 +252,8 @@ func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery, {OptionApprove, "Approve", "the pull request is merged into main at the head named (a group: all its members, in one pass)"}, {OptionDecline, "Decline", "nothing is merged"}, } { - binds, _ := asks.ActDigest(mergeAct(id, o.id, heads)) - a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds}) + binds, _ := occ.ActDigest(mergeAct(id, o.id, heads)) + a.Options = append(a.Options, oc.Option{ID: o.id, Label: o.label, Does: o.does, Level: occ.Approve, Binds: binds}) } return a } @@ -458,18 +459,18 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) { group = d.Group } heads := map[string]string{} - var covered []sdk.Head + var covered []mdc.Head for _, m := range members { heads[m.ID] = m.Commit - covered = append(covered, sdk.Head{Repository: m.Repository, Number: m.Number, Head: m.Commit}) + covered = append(covered, mdc.Head{Repository: m.Repository, Number: m.Number, Head: m.Commit}) } name := askGroup(d) - id, err := sdk.MergeAskKey(name, covered) + id, err := mdc.MergeAskKey(name, covered) if err != nil { return "the operator cannot be asked: " + err.Error(), nil } r.Asks++ - // Each asking at these heads has its own router id (sdk.AskingID): the router keeps an ask by its id and drops a second ask + // Each asking at these heads has its own router id (mdc.AskingID): the router keeps an ask by its id and drops a second ask // under it as a redelivery, so an ask written again at unchanged heads (a lapse, a status or the review record changing) is // the next asking, with a new id (review round 1, G2; issue 1370). asking := 1 @@ -479,7 +480,7 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) { } } check := h.checkAt(members) - ask := composeMergeAsk(sdk.AskingID(id, asking), d, group, members, heads, check, now) + ask := composeMergeAsk(mdc.AskingID(id, asking), d, group, members, heads, check, now) taken := []*Delivery{} var reads []memberRead for _, m := range members { @@ -573,7 +574,11 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) { h.keepGroup(g) h.mu.Unlock() } - if err := h.Operator.Ask(id, sdk.Ask{Group: name, Heads: covered, Asking: asking, Ask: ask}); err == nil { + toAsk, err := gateAsk(name, covered, asking, ask) + if err == nil { + err = h.Operator.Ask(id, toAsk) + } + if err == nil { h.mu.Lock() h.markWritten(id, true) h.mu.Unlock() @@ -721,7 +726,7 @@ func (h *Holder) hearAsks() { // held the warrant to the ask (novox/hq ADR 0437 §6); this owner acts on the answer only if the key it was written under is // still the key of the heads asked: an answer to an ask taken away, or to other heads, merges nothing (ADR 0441 §1). It // returns what to say on the pull requests. -func (h *Holder) answered(key string, ans sdk.Answer) []func() { +func (h *Holder) answered(key string, ans Answer) []func() { var covered []*Delivery for _, d := range h.deliveries { if d.Ready != nil && d.Ready.AskID == key { @@ -742,24 +747,24 @@ func (h *Holder) answered(key string, ans sdk.Answer) []func() { h.keep(d) } } - var heads []sdk.Head + var heads []mdc.Head for id, sha := range first.Heads { m := h.deliveries[id] if m == nil { refuse("the head " + id + " it covers is no delivery of this owner") return nil } - heads = append(heads, sdk.Head{Repository: m.Repository, Number: m.Number, Head: sha}) + heads = append(heads, mdc.Head{Repository: m.Repository, Number: m.Number, Head: sha}) } - if want, err := sdk.MergeAskKey(askGroup(covered[0]), heads); err != nil || want != key { + if want, err := mdc.MergeAskKey(askGroup(covered[0]), heads); err != nil || want != key { refuse(fmt.Sprintf("its key %s is not the key %s of the heads asked", key, want)) return nil } // The answer is to the asking asked now: the teller names the router id it was given in the warrant's id (`@