Compare commits
184
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c9c8a5631 | ||
|
|
bc4d43b508 | ||
|
|
7a5eef32e7 | ||
|
|
5aaafd917f | ||
|
|
aa7c9e8924 | ||
|
|
80f992e367 | ||
|
|
b3fc5b4580 | ||
|
|
6fb27549c9 | ||
|
|
d8227222a7 | ||
|
|
c9d8950fd0 | ||
|
|
df8168f484 | ||
|
|
657cb2b6c8 | ||
|
|
cbea2470d2 | ||
|
|
2dc9e07e88 | ||
|
|
1feda1f533 | ||
|
|
4b9ea0bf11 | ||
|
|
625d627794 | ||
|
|
d630635229 | ||
|
|
29bcaf2be5 | ||
|
|
78a1b5648b | ||
|
|
720efe6339 | ||
|
|
76e25aeee0 | ||
|
|
fb6bf361e4 | ||
|
|
8a8ad7713e | ||
|
|
50acf7c1b4 | ||
|
|
a89aaf5644 | ||
|
|
82d00aeb5e | ||
|
|
2978dd5bed | ||
|
|
1f3a293988 | ||
|
|
bbcc95e66d | ||
|
|
37cdf41088 | ||
|
|
6f2e27f59b | ||
|
|
8d3cffab9a | ||
|
|
73eb3bd11c | ||
|
|
75959dfa25 | ||
|
|
36da33c818 | ||
|
|
ef81dc2712 | ||
|
|
2685b9c749 | ||
|
|
725ea4dba4 | ||
|
|
ffa026f7d8 | ||
|
|
da560f90f8 | ||
|
|
df53a0c59e | ||
|
|
634abead7d | ||
|
|
85999b7efd | ||
|
|
b8642c8b27 | ||
|
|
8f1581f34a | ||
|
|
1e974ce9e8 | ||
|
|
941959198e | ||
|
|
d8b4bb5487 | ||
|
|
078f1e5fa9 | ||
|
|
aa29d8feb8 | ||
|
|
cfde90906f | ||
|
|
8786bb3ba1 | ||
|
|
72f22b11ec | ||
|
|
84a7639042 | ||
|
|
6e4de66df7 | ||
|
|
d4abb82b07 | ||
|
|
42387b660c | ||
|
|
0f1d4c5e0f | ||
|
|
597910c291 | ||
|
|
8fabdc4b91 | ||
|
|
a609ebc8e5 | ||
|
|
a636dc3df3 | ||
|
|
72dd8a719e | ||
|
|
4923e44cf7 | ||
|
|
1d20825d69 | ||
|
|
45f4edbb36 | ||
|
|
c39c3fdddd | ||
|
|
b0f2441dd3 | ||
|
|
62bb25675d | ||
|
|
4104143d16 | ||
|
|
916bdde6a4 | ||
|
|
97cc9b1ccf | ||
|
|
0ae418b43c | ||
|
|
1d3ed6e78e | ||
|
|
f61760f738 | ||
|
|
c46009709d | ||
|
|
b4dbda287b | ||
|
|
e0eecbf3c8 | ||
|
|
c7ae96414f | ||
|
|
9017edaf9e | ||
|
|
75cf83a656 | ||
|
|
059fa5d4c4 | ||
|
|
60c66a3641 | ||
|
|
631f689f40 | ||
|
|
500154b865 | ||
|
|
12b5c62304 | ||
|
|
b2db504ab2 | ||
|
|
e8f93d5db0 | ||
|
|
7c617bb085 | ||
|
|
87388b9046 | ||
|
|
9fafbcfd58 | ||
|
|
2f7ff1c911 | ||
|
|
7ae2ed88c0 | ||
|
|
a86e2ff1fc | ||
|
|
518539f3d1 | ||
|
|
a2272916a0 | ||
|
|
2b2b3e4b8c | ||
|
|
1abae72457 | ||
|
|
0beef7ff9c | ||
|
|
559939aa8c | ||
|
|
87fd0cd8b9 | ||
|
|
279ac41087 | ||
|
|
b0bdcd02c9 | ||
|
|
211186edd7 | ||
|
|
5ceb6e9e38 | ||
|
|
d023a25849 | ||
|
|
ebe1248f0b | ||
|
|
8377f8a878 | ||
|
|
796fb69d17 | ||
|
|
236ce2fe12 | ||
|
|
bbb9e37df4 | ||
|
|
1bf98663c6 | ||
|
|
541c2e02d9 | ||
|
|
fd4931a752 | ||
|
|
64cb7ecc53 | ||
|
|
10bbbadcf2 | ||
|
|
3dfc3533be | ||
|
|
da2aa91832 | ||
|
|
1332f8b523 | ||
|
|
af487d38ed | ||
|
|
cea09d8a36 | ||
|
|
b4e0518282 | ||
|
|
d0a69afbb1 | ||
|
|
4d3c901b99 | ||
|
|
680da05c4c | ||
|
|
d8af436536 | ||
|
|
1f9b130225 | ||
|
|
5ab349df6c | ||
|
|
c50a7d059a | ||
|
|
c275919f11 | ||
|
|
4a3d7b60a1 | ||
|
|
f3983ed473 | ||
|
|
480556a329 | ||
|
|
ac29f20517 | ||
|
|
f86a1f2a36 | ||
|
|
dad2708805 | ||
|
|
80cdcbfa2f | ||
|
|
4b93dbb04e | ||
|
|
ddcd790304 | ||
|
|
34269df920 | ||
|
|
1af1a7b002 | ||
|
|
b5644829d3 | ||
|
|
cd3e444a76 | ||
|
|
ce989b001a | ||
|
|
7a60771165 | ||
|
|
6cd4aedb7c | ||
|
|
4c4abb1b66 | ||
|
|
b4ab61c0aa | ||
|
|
39cd545814 | ||
|
|
5ef6287ce4 | ||
|
|
7cd9b611d2 | ||
|
|
1f79a4d52a | ||
|
|
65c2d76506 | ||
|
|
d3fa102e4d | ||
|
|
fd908a444d | ||
|
|
b4e7ffd89c | ||
|
|
ad671183bf | ||
|
|
d3d4dbc8ed | ||
|
|
05dd17ebaa | ||
|
|
e71b006d85 | ||
|
|
725eea8a68 | ||
|
|
3f39b60d9a | ||
|
|
880de92bc8 | ||
|
|
6ec562e964 | ||
|
|
2c807a3800 | ||
|
|
2770a55b61 | ||
|
|
9d83987815 | ||
|
|
87a30055a6 | ||
|
|
a46e52000b | ||
|
|
aad5c6ceea | ||
|
|
c295176048 | ||
|
|
ea4e22577c | ||
|
|
bcac2d6d1c | ||
|
|
4d597eb9dc | ||
|
|
7843edb1eb | ||
|
|
b00a826681 | ||
|
|
d618cbb9ca | ||
|
|
550bb4fdd1 | ||
|
|
f62c2ff380 | ||
|
|
2339ac5af7 | ||
|
|
5bc05201ef | ||
|
|
9e726d4c41 | ||
|
|
aff9d6ae6a |
+229
-42
@@ -1,49 +1,236 @@
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# Go tool bundles built in place (go build in a module's cmd/<name>-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/mesh-conditions/mesh-conditions
|
||||
modules/minio/object-store
|
||||
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/<name>), 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
|
||||
/checks/paths/paths
|
||||
# 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/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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -2,6 +2,5 @@ module checks/credentialform
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
|
||||
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
||||
|
||||
require git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
|
||||
|
||||
@@ -122,3 +122,171 @@ func Find(root string, dirs []string) ([]Hidden, error) {
|
||||
}
|
||||
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 ./<dir>` 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
|
||||
}
|
||||
|
||||
@@ -78,3 +78,81 @@ func TestNoIgnorePatternHidesAModulesSource(t *testing.T) {
|
||||
t.Errorf("%s is hidden by %s: a module's source is never ignored; anchor the pattern to the binary (/<name>, /cmd/<name>/<name>)", 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,5 @@ module checks/health
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
|
||||
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
||||
|
||||
require git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
|
||||
|
||||
@@ -2,6 +2,5 @@ module checks/manifests
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
|
||||
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
||||
|
||||
require git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+11
-2
@@ -2,6 +2,15 @@ module checks/readable
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
|
||||
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
|
||||
)
|
||||
|
||||
require git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
|
||||
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
|
||||
)
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"}`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -109,7 +109,7 @@ func TestCheckAddressesRefusesTheControllersAddresses(t *testing.T) {
|
||||
{"`mesh-controller.<verb>`", 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 {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
57
|
||||
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
|
||||
|
||||
@@ -2,7 +2,7 @@ module adwaita
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
|
||||
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
||||
|
||||
require (
|
||||
github.com/klauspost/compress v1.20.1 // indirect
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
|
||||
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=
|
||||
|
||||
@@ -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,16 +95,14 @@ 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
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"
|
||||
@@ -14,23 +15,43 @@ import (
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
|
||||
"artifactstoretools/internal/registry"
|
||||
)
|
||||
|
||||
// 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")
|
||||
|
||||
// It is read only: what the sweep holds and removes it asks the registry seats (sweepseats.go).
|
||||
func (d *door) Address() string { return d.s.Address }
|
||||
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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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
|
||||
}
|
||||
|
||||
// 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")
|
||||
@@ -43,7 +64,7 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -18,35 +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"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
|
||||
"artifactstoretools/internal/registry"
|
||||
)
|
||||
|
||||
// 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).
|
||||
@@ -71,7 +73,7 @@ type Bus interface {
|
||||
Publish(subject string, body any) error
|
||||
}
|
||||
|
||||
// Door is the artifact store's own door, read only: registry.Store, behind an interface so a test needs no store. What
|
||||
// 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
|
||||
@@ -83,9 +85,9 @@ type Door interface {
|
||||
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
|
||||
Acts meshhandactsclient.Recorder
|
||||
// Seats asks a registry seat's verb, `seat:<seat>.<verb>` (hold, remove): the runtime's stdio.Ask in main.
|
||||
Seats func(key string, body any) (json.RawMessage, error)
|
||||
Now func() time.Time
|
||||
@@ -100,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
|
||||
}
|
||||
@@ -113,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
|
||||
}
|
||||
@@ -124,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)
|
||||
@@ -150,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
|
||||
}
|
||||
}
|
||||
@@ -182,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
|
||||
}
|
||||
@@ -218,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 := canonical(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]
|
||||
@@ -353,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
|
||||
}
|
||||
@@ -384,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"}
|
||||
}
|
||||
}
|
||||
@@ -398,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 {
|
||||
@@ -446,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
|
||||
}
|
||||
@@ -576,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
|
||||
@@ -599,7 +601,7 @@ func (h *Holder) Sweep(ctx context.Context, s Sweeping) (*CollectAnswer, error)
|
||||
refused = true
|
||||
break
|
||||
}
|
||||
if _, err := h.Acts.Record(handacts.Request{What: "let go of " + r + " (" + s.By + ")", Why: why,
|
||||
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)
|
||||
}
|
||||
@@ -614,7 +616,7 @@ func (h *Holder) Sweep(ctx context.Context, s Sweeping) (*CollectAnswer, error)
|
||||
|
||||
// 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]
|
||||
@@ -624,7 +626,8 @@ 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)
|
||||
}
|
||||
@@ -633,7 +636,7 @@ func (h *Holder) sayGone(j *judged, refs []string, how string) {
|
||||
}
|
||||
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))
|
||||
}
|
||||
@@ -647,7 +650,7 @@ func (h *Holder) sayGone(j *judged, refs []string, how string) {
|
||||
// artifacts-gone against its own key.
|
||||
func namedBy(b Build, r string) string {
|
||||
for _, a := range b.Artifacts {
|
||||
if rec := catalogue.Recorded(a.Reference); canonical(rec) == r {
|
||||
if rec := registryForms.Recorded(a.Reference); canonical(rec) == r {
|
||||
return rec
|
||||
}
|
||||
}
|
||||
@@ -667,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, 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 {
|
||||
@@ -726,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
|
||||
}
|
||||
@@ -734,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)
|
||||
@@ -775,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
|
||||
}
|
||||
@@ -783,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
|
||||
}
|
||||
}
|
||||
@@ -807,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
|
||||
}
|
||||
@@ -815,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
|
||||
@@ -842,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
|
||||
@@ -867,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 {
|
||||
@@ -876,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.
|
||||
@@ -907,7 +910,7 @@ func (h *Holder) Observe(ctx context.Context) error {
|
||||
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 := health.DecodeFinding(raw); err == nil && f.Kind == views.UnansweredKind {
|
||||
if f, err := meshconditions.DecodeFindingsByKeyValue(raw); err == nil && f.Kind == views.UnansweredKind {
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -922,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 {
|
||||
@@ -937,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,
|
||||
@@ -949,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()}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -198,7 +199,7 @@ func TestTheSeatSweepsAtABuildsEndOnARealBusWithNoLease(t *testing.T) {
|
||||
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 = handacts.Recorder{Ask: ask}
|
||||
h.Acts = meshhandactsclient.Recorder{Ask: ask}
|
||||
|
||||
// A build's end heard from the builds bucket, as follow hands it over.
|
||||
kv, _ := bus.kv(BuildsRead)
|
||||
|
||||
@@ -5,6 +5,11 @@ import (
|
||||
"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"
|
||||
@@ -12,14 +17,14 @@ import (
|
||||
"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"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
|
||||
"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.
|
||||
@@ -34,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)
|
||||
}
|
||||
}
|
||||
@@ -107,9 +112,9 @@ func (d *memDoor) seat(key string, body any) (json.RawMessage, error) {
|
||||
var in struct{ Name, Digest, Reason string }
|
||||
_ = json.Unmarshal(raw, &in)
|
||||
seat, verb, _ := strings.Cut(strings.TrimPrefix(key, "seat:"), ".")
|
||||
ref := catalogue.ArtifactStoreScheme + in.Name + "@" + in.Digest
|
||||
ref := artifactref.ArtifactStoreScheme + in.Name + "@" + in.Digest
|
||||
if seat == "oci-artifact-registry" {
|
||||
ref = catalogue.ArtifactStoreScheme + in.Name + "/blobs/" + in.Digest
|
||||
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 {
|
||||
@@ -141,7 +146,7 @@ func (d *memDoor) seat(key string, body any) (json.RawMessage, error) {
|
||||
func (d *memDoor) Address() string { return "10.0.0.5:5000" }
|
||||
func (d *memDoor) Holds(_ context.Context, r string) error {
|
||||
if !d.holds[r] {
|
||||
return registry.Gone
|
||||
return Gone
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -176,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)}}})
|
||||
@@ -205,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:
|
||||
@@ -213,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 }, 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
|
||||
@@ -303,10 +308,10 @@ func TestTheSeatKeepsTheFiveNewestAndTheWantedAndSweepsWithNoLease(t *testing.T)
|
||||
}
|
||||
if c.seatForm {
|
||||
for _, n := range []int{2, 3} {
|
||||
key, _ := delivery.BuilderBuildKey("letta", fp(n))
|
||||
raw, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: strings.Repeat("d", 64), Artifacts: []delivery.BuiltArtifact{
|
||||
{Name: "image", Kind: "image", Digest: digestOf(imageRef("letta", n)), Reference: catalogue.SeatReference("container-image-registry", "letta", "image", digestOf(imageRef("letta", n)))},
|
||||
{Name: "files", Kind: "archive", Digest: digestOf(archiveRef("letta", n)), Reference: catalogue.SeatReference("oci-artifact-registry", "letta", "files", digestOf(archiveRef("letta", n)))}}})
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -405,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)
|
||||
}
|
||||
@@ -427,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) ||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
// 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 (
|
||||
@@ -15,9 +13,9 @@ import (
|
||||
"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/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/handacts"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
)
|
||||
@@ -25,17 +23,13 @@ import (
|
||||
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(),
|
||||
}
|
||||
v := &views.Reader{Module: "artifact-store-tools", Logf: logf, Findings: views.OwnFindings{}, Acts: handacts.Recorder{Ask: stdio.Ask}}
|
||||
v.Follow(delivery.AssignerModule, "wants", WantsTool)
|
||||
v.Follow(delivery.IndexerModule, "manifests", ManifestsTool)
|
||||
h := &Holder{Bus: runtimeBus{views: v}, Door: DoorAt(os.Getenv("MESH_ARTIFACT_STORE_ADDRESS_FILE")), Listed: s.View,
|
||||
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 = handacts.Recorder{Ask: stdio.Ask}
|
||||
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.
|
||||
@@ -47,6 +41,6 @@ func main() {
|
||||
|
||||
// The owners' read tools of the states the holder follows, which the manifest invokes (ADR 0615).
|
||||
const (
|
||||
WantsTool = delivery.AssignerModule + ".assigner_read_wants"
|
||||
ManifestsTool = delivery.IndexerModule + ".indexer_read_manifests"
|
||||
WantsTool = assignerModule + ".assigner_read_wants"
|
||||
ManifestsTool = indexerModule + ".indexer_read_manifests"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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.<verb>` 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.",
|
||||
|
||||
@@ -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/<hex>/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/<repo>/_manifests/revisions/sha256/<hex>/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/<repo>/_manifests/tags/<tag>/current/link:sha256:<hex>
|
||||
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
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
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"
|
||||
@@ -24,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})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,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)
|
||||
@@ -170,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,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"])
|
||||
@@ -277,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)
|
||||
}
|
||||
}
|
||||
@@ -395,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
|
||||
@@ -449,6 +440,7 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
|
||||
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"`
|
||||
@@ -475,7 +467,9 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
|
||||
// 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"}
|
||||
"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)
|
||||
}
|
||||
@@ -484,16 +478,27 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
@@ -503,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,23 +14,25 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
ociseat "git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry"
|
||||
"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"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
|
||||
reg "git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry"
|
||||
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 = health.FindingKey(health.ScopeModule, Module, SweepRefused)
|
||||
var sweepFindingKey = meshconditionsclient.Key(meshconditionsclient.ScopeModule, Module, SweepRefused)
|
||||
|
||||
// canonical is a kept reference in the older form, `artifact-store://<path>`, whichever form it came in; another is as
|
||||
// it was.
|
||||
func canonical(reference string) string {
|
||||
path, kept := catalogue.InArtifactStore(reference)
|
||||
path, kept := registryForms.InArtifactStore(reference)
|
||||
if !kept {
|
||||
return reference
|
||||
}
|
||||
@@ -40,7 +42,7 @@ func canonical(reference string) string {
|
||||
// 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 := catalogue.InArtifactStore(reference)
|
||||
path, kept := registryForms.InArtifactStore(reference)
|
||||
if !kept {
|
||||
return "", "", "", fmt.Errorf("%s is no reference into the mesh's registries", reference)
|
||||
}
|
||||
@@ -55,7 +57,7 @@ func seatOf(reference string) (seat, name, digest string, err error) {
|
||||
|
||||
// isCopy is whether a kept reference is a copy of an upstream base.
|
||||
func isCopy(reference string) bool {
|
||||
path, _ := catalogue.InArtifactStore(reference)
|
||||
path, _ := registryForms.InArtifactStore(reference)
|
||||
return strings.HasPrefix(path, "upstream/")
|
||||
}
|
||||
|
||||
@@ -113,16 +115,16 @@ func (h *Holder) removeOnSeat(reference, why string) error {
|
||||
// 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 := health.Finding{V: health.FindingVersion, Key: sweepFindingKey, Kind: SweepRefused,
|
||||
Subject: health.Subject{Scope: health.ScopeModule, ID: Module},
|
||||
Severity: health.Warning, Resolver: health.ResolverOperator,
|
||||
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: []health.Evidence{{At: at, Said: fmt.Sprintf("%s: %v", what, err)}},
|
||||
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)
|
||||
@@ -145,3 +147,7 @@ func keptWhy(r *Ref) string {
|
||||
}
|
||||
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}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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/<name>/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/<name>/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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1 @@
|
||||
${seat:mesh-artifact-store:reach}
|
||||
${seat:container-image-registry:reach}
|
||||
|
||||
@@ -3,15 +3,27 @@ module artifactstoretools
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient 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/meshwants 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/go/clients/nodeengineclient 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/modules/mesh-delivery-builder/builds 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/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/meshhandacts 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/ociartifactregistry 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 git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // indirect
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry v0.0.0-20261104174344-ae5266e77639
|
||||
git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry v0.0.0-20261104174344-ae5266e77639
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep v0.0.0-00010101000000-000000000000
|
||||
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
|
||||
@@ -24,3 +36,7 @@ require (
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/time v0.16.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 // indirect
|
||||
)
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry v0.0.0-20261104174344-ae5266e77639 h1:5HeZzk6tKzcSwVivD3IcIvQ/2WIvysZvNvNKyq/oIBA=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry v0.0.0-20261104174344-ae5266e77639/go.mod h1:pPxwhTMb3hIk7UUrBicJnGQCqfTEoBGJvNFspEK2U18=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry v0.0.0-20261104174344-ae5266e77639 h1:+bkN8op2yrAlBYrOB+hYnIoIZkbjXsNmc/4//uMnL4E=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry v0.0.0-20261104174344-ae5266e77639/go.mod h1:ooQa2CiP8JOvhP0bdQvwurV5wskRE7Cm68ecURBbCwU=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
|
||||
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=
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
// Package registry reads the mesh's artifact store over its own door: whether it holds what a reference names, and
|
||||
// whether a manifest is an index. It writes nothing: what the artifact sweep holds and removes it asks the registry
|
||||
// seats the store's holder serves (CAT-AS1, novox/hq ADR 0617 §2).
|
||||
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
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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: `<repository>@sha256:<hex>` is a manifest, and
|
||||
// `<repository>/blobs/sha256:<hex>` 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// The store's door is read only (CAT-AS1: what the sweep holds and removes it asks the registry seats): Holds says
|
||||
// whether the store holds what a reference names, in either reference form, IsIndex whether it is an index; a reference
|
||||
// that is not the mesh's own is never asked. A fake store answers by path and records what it was asked.
|
||||
func TestTheDoorReadsWhatTheStoreHoldsAndWritesNothing(t *testing.T) {
|
||||
const index = `{"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"digest":"sha256:c1"}]}`
|
||||
const image = `{"mediaType":"application/vnd.oci.image.manifest.v1+json","layers":[]}`
|
||||
var asked []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
asked = append(asked, r.Method+" "+r.URL.Path)
|
||||
switch r.URL.Path {
|
||||
case "/v2/web/app/manifests/sha256:abc", "/v2/web/files/blobs/sha256:def":
|
||||
if r.Method == http.MethodGet {
|
||||
_, _ = w.Write([]byte(image))
|
||||
} else {
|
||||
w.Header().Set("Content-Length", "7")
|
||||
}
|
||||
case "/v2/web/app/manifests/sha256:idx":
|
||||
if r.Method == http.MethodGet {
|
||||
_, _ = w.Write([]byte(index))
|
||||
}
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
store := Store{Address: strings.TrimPrefix(srv.URL, "http://")}
|
||||
ctx := context.Background()
|
||||
for name, c := range map[string]struct {
|
||||
reference string
|
||||
holds error // nil, Gone or ErrNotOurs
|
||||
index bool
|
||||
}{
|
||||
"an image": {reference: catalogue.ArtifactStoreScheme + "web/app@sha256:abc"},
|
||||
"an image in the seat's form": {reference: catalogue.SeatReference("container-image-registry", "web", "app", "sha256:abc")},
|
||||
"an archive": {reference: catalogue.ArtifactStoreScheme + "web/files/blobs/sha256:def"},
|
||||
"an archive in the seat's form": {reference: catalogue.SeatReference("oci-artifact-registry", "web", "files", "sha256:def")},
|
||||
"an index": {reference: catalogue.ArtifactStoreScheme + "web/app@sha256:idx", index: true},
|
||||
"an image the store lacks": {reference: catalogue.ArtifactStoreScheme + "web/app@sha256:999", holds: Gone},
|
||||
"an archive the store lacks": {reference: catalogue.ArtifactStoreScheme + "web/files/blobs/sha256:999", holds: Gone},
|
||||
"another registry's image": {reference: "docker.io/library/alpine@sha256:abc", holds: ErrNotOurs},
|
||||
"a reference naming no digest": {reference: catalogue.ArtifactStoreScheme + "web/app:latest", holds: ErrNotOurs},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
asked = nil
|
||||
if err := store.Holds(ctx, c.reference); !errors.Is(err, c.holds) && (err != nil || c.holds != nil) {
|
||||
t.Errorf("Holds = %v, want %v", err, c.holds)
|
||||
}
|
||||
if c.holds == nil {
|
||||
if got, err := store.IsIndex(ctx, c.reference); err != nil || got != c.index {
|
||||
t.Errorf("IsIndex = %v, %v, want %v", got, err, c.index)
|
||||
}
|
||||
}
|
||||
for _, a := range asked {
|
||||
if !strings.HasPrefix(a, "HEAD ") && !strings.HasPrefix(a, "GET ") {
|
||||
t.Errorf("the door asked %s: it writes nothing", a)
|
||||
}
|
||||
}
|
||||
if errors.Is(c.holds, ErrNotOurs) && len(asked) != 0 {
|
||||
t.Errorf("a reference that is not the mesh's own was asked: %v", asked)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"module": "artifact-store-tools",
|
||||
"version": "1",
|
||||
"packages": {
|
||||
"builds": "0.1"
|
||||
},
|
||||
"claims": [
|
||||
{
|
||||
"name": "mesh-artifact-sweep",
|
||||
@@ -14,9 +17,6 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
"artifact-store"
|
||||
],
|
||||
"invokes": [
|
||||
"seat:mesh-hand-acts.hand-act",
|
||||
"seat:mesh-catalog.modules",
|
||||
@@ -28,7 +28,9 @@
|
||||
"seat:oci-artifact-registry.remove",
|
||||
"seat:container-image-registry.hold",
|
||||
"seat:container-image-registry.release",
|
||||
"seat:container-image-registry.remove"
|
||||
"seat:container-image-registry.remove",
|
||||
"seat:container-image-registry.list",
|
||||
"seat:oci-artifact-registry.list"
|
||||
],
|
||||
"reads": [
|
||||
"mesh-delivery-builder.builds",
|
||||
@@ -96,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"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ module asuszephyrusg14
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
|
||||
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
||||
|
||||
require (
|
||||
github.com/klauspost/compress v1.20.1 // indirect
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
|
||||
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=
|
||||
|
||||
@@ -2,7 +2,7 @@ module avahi
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
|
||||
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
||||
|
||||
require (
|
||||
github.com/klauspost/compress v1.20.1 // indirect
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
|
||||
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=
|
||||
|
||||
@@ -2,7 +2,7 @@ module blueman
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
|
||||
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
||||
|
||||
require (
|
||||
github.com/klauspost/compress v1.20.1 // indirect
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
|
||||
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=
|
||||
|
||||
@@ -2,7 +2,7 @@ module bluetooth
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
|
||||
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
||||
|
||||
require (
|
||||
github.com/klauspost/compress v1.20.1 // indirect
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
|
||||
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=
|
||||
|
||||
@@ -3,13 +3,12 @@ module brother
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
|
||||
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 (
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 // 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
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
|
||||
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=
|
||||
|
||||
@@ -122,7 +122,7 @@ The agent kept reaching for `ssh <machine> 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: `<node>/<seat>.` 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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -275,11 +275,11 @@ func TestTheManifestListsEveryToolServed(t *testing.T) {
|
||||
Consumes []string `json:"consumes"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
// The hand-act seat's warranted and hand-act, and the owners' read tools of the three followed states; no verb of
|
||||
// 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", ModulesTool, ManifestsTool, PlacementTool}) ||
|
||||
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.placement"}) {
|
||||
!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"`) ||
|
||||
|
||||
@@ -25,8 +25,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
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.
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||
)
|
||||
|
||||
const agentToken = "0123456789abcdef0123456789abcdef01234567"
|
||||
|
||||
@@ -9,20 +9,20 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/go/seats"
|
||||
"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 {
|
||||
defined := []seats.Seat{
|
||||
{Name: "mesh-placement", Scope: "mesh", Serves: []seats.Verb{
|
||||
{Name: "placement", Description: "Read placement: one node's key.", Replaces: []string{"hostnamectl", "uptime"}}}},
|
||||
{Name: "node-service-manager", Scope: "node", Serves: []seats.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: "node", Serves: []seats.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"}}},
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -6,14 +6,19 @@ 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"
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/go/seats"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -37,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() []seats.Seat {
|
||||
return []seats.Seat{
|
||||
{Name: "mesh-placement", Scope: "mesh", Serves: []seats.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: "node", Serves: []seats.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{
|
||||
@@ -71,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
|
||||
}
|
||||
@@ -100,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)
|
||||
@@ -120,7 +159,7 @@ func TestTheInsteadOfTableIsGeneratedFromTheRecords(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"| `<node>/node-service-manager.` | `status` — `systemctl status`, `systemctl is-active` · `journal` — `journalctl` |",
|
||||
"| `<node>/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) {
|
||||
@@ -128,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>/node-service-manager.` | `journal` — `journalctl` · `status`") ||
|
||||
strings.Index(ranked, "`mesh-placement.`") > strings.Index(ranked, "`<node>/docker.`") {
|
||||
strings.Index(ranked, "`mesh-module-assigner.`") > strings.Index(ranked, "`<node>/docker.`") {
|
||||
t.Errorf("not ranked by the guard's record:\n%s", ranked)
|
||||
}
|
||||
m.Refused = nil
|
||||
@@ -182,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)
|
||||
@@ -192,17 +231,17 @@ 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 placement come from the owners' first views and their views events, never their state.
|
||||
// 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, PlacementRead} {
|
||||
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] {
|
||||
@@ -216,16 +255,16 @@ func TestTheMeshIsReadFromTheOwnersFirstViews(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
want, err := ReadMeshToolsFrom(bus, seats.List(), resolving)
|
||||
want, err := ReadMeshToolsFrom(bus, someSeats(), resolving)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ReadMeshToolsFrom(viewStates{r}, seats.List(), resolving)
|
||||
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()}, seats.List(), resolving); err == nil || !strings.Contains(err.Error(), "no first view") {
|
||||
if _, err := ReadMeshToolsFrom(viewStates{newMeshViews()}, someSeats(), resolving); err == nil || !strings.Contains(err.Error(), "no first view") {
|
||||
t.Fatalf("an unanswered first view: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -233,7 +272,7 @@ func TestTheMeshIsReadFromTheOwnersFirstViews(t *testing.T) {
|
||||
// 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), seats.List(), resolving)
|
||||
m, err := ReadMeshToolsFrom(aMesh(t), someSeats(), resolving)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -245,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 }
|
||||
|
||||
@@ -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 `<seat>.<verb>` (the mesh's own seats\n include `mesh-placement`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\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>/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 `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\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>/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"
|
||||
}
|
||||
|
||||
@@ -22,12 +22,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
"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/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/handacts"
|
||||
"git.novox.be/novox/mesh-sdk/go/seatlist"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
)
|
||||
|
||||
@@ -210,17 +209,24 @@ 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 assigner's placement — for the register tool's question.
|
||||
// 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") }
|
||||
|
||||
// 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 (
|
||||
ModulesTool = delivery.IndexerModule + ".indexer_read_modules"
|
||||
ManifestsTool = delivery.IndexerModule + ".indexer_read_manifests"
|
||||
PlacementTool = delivery.AssignerModule + ".assigner_read_placement"
|
||||
assignerModule = "mesh-delivery-assigner"
|
||||
indexerModule = "mesh-delivery-indexer"
|
||||
)
|
||||
|
||||
// meshViews is this module's picture of the indexer's modules and manifests and the assigner's placement: each asked
|
||||
const (
|
||||
ModulesTool = indexerModule + ".indexer_read_modules"
|
||||
ManifestsTool = indexerModule + ".indexer_read_manifests"
|
||||
AssignmentsTool = assignerModule + ".assigner_read_assignments"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
@@ -229,17 +235,17 @@ 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: views.OwnFindings{},
|
||||
Acts: handacts.Recorder{Ask: stdio.Ask},
|
||||
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(delivery.IndexerModule, "modules", ModulesTool)
|
||||
v.Follow(delivery.IndexerModule, "manifests", ManifestsTool)
|
||||
v.Follow(delivery.AssignerModule, "placement", PlacementTool)
|
||||
v.Follow(indexerModule, "modules", ModulesTool)
|
||||
v.Follow(indexerModule, "manifests", ManifestsTool)
|
||||
v.Follow(assignerModule, "assignments", AssignmentsTool)
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -319,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(viewStates{meshViews}, seats.List(), 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)
|
||||
@@ -406,6 +412,7 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
|
||||
}
|
||||
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)...)
|
||||
}
|
||||
|
||||
@@ -659,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
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ package main
|
||||
// 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-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-contracts/go/seats"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"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(defined []seats.Seat, modules []ModuleTools) []Replacement {
|
||||
func ReplacementsOf(defined []seatdef.Definition, modules []ModuleTools) []Replacement {
|
||||
var out []Replacement
|
||||
for _, s := range defined {
|
||||
for _, v := range s.Serves {
|
||||
for _, v := range s.Verbs {
|
||||
if len(v.Replaces) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, Replacement{Address: s.Name + "." + v.Name, NodeScoped: s.Scope == "node", 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,11 +261,11 @@ 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, defined []seats.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(defined, nil)
|
||||
return m, err
|
||||
@@ -334,7 +340,7 @@ func guardFiles(p Paths, m *MeshTools) *GuardFiles {
|
||||
return &GuardFiles{Exe: exe, Data: GuardDataOf(p, m), WaitsDir: WaitsDirPath(p)}
|
||||
}
|
||||
|
||||
// MeshToolsEvery is how often the machines' addresses are resolved again; the tools and the placement are 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
|
||||
|
||||
@@ -347,10 +353,11 @@ func refreshMeshTools(p Paths) {
|
||||
select {
|
||||
case <-meshViews.Ready():
|
||||
case <-time.After(MeshToolsFirst):
|
||||
say("the first views of the tools and the placement were not answered in %s; reading what is held", 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(viewStates{meshViews}, seats.List(), 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)
|
||||
}
|
||||
@@ -403,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
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
@@ -116,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 {
|
||||
|
||||
@@ -42,8 +42,8 @@ import (
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -113,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.
|
||||
@@ -166,9 +170,14 @@ 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) occ.Act {
|
||||
return occ.Act{"verb": Module + ".instructions", "option": option, "proposal": p.ID, "action": p.Action,
|
||||
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).
|
||||
@@ -199,8 +208,18 @@ func (p Proposal) glanceAsk() oc.Ask {
|
||||
}
|
||||
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")
|
||||
@@ -209,6 +228,9 @@ func (p Proposal) glanceAsk() oc.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"
|
||||
@@ -220,6 +242,16 @@ func (p Proposal) glanceAsk() oc.Ask {
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -461,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 {
|
||||
@@ -474,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 ""
|
||||
}
|
||||
|
||||
@@ -489,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"
|
||||
@@ -515,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"
|
||||
}
|
||||
@@ -525,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"
|
||||
@@ -546,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.
|
||||
@@ -652,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
|
||||
}
|
||||
@@ -810,30 +865,42 @@ 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 != "" {
|
||||
@@ -1168,15 +1235,22 @@ func decide(p Paths, approved *Approved, prop Proposal, w oc.Warrant, now time.T
|
||||
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] = 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: at.UTC(), By: occ.Says(w)}
|
||||
Digest: prop.Digest, Proposal: prop.ID, From: prop.From, Approved: at.UTC(), By: occ.Says(w), Over: prop.Over}
|
||||
}
|
||||
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))
|
||||
@@ -1211,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 {
|
||||
@@ -1252,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
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -399,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 `<seat>.<verb>` (the mesh's own seats\n include `mesh-placement`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\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>/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 `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\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>/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 `<seat>.<verb>` (the mesh's own seats\n include `mesh-placement`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\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>/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 `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\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>/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"
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,17 @@ module claude-code
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
|
||||
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639
|
||||
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104174344-ae5266e77639
|
||||
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104174344-ae5266e77639
|
||||
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-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
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -19,3 +24,8 @@ require (
|
||||
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
|
||||
)
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
|
||||
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104174344-ae5266e77639 h1:19PhtrCOwia711Oqdyafvs5EKTVmmgk5Gdb4leCkq3w=
|
||||
git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient v0.0.0-20261104174344-ae5266e77639/go.mod h1:Dj6fbL8Evgy72EWO3Gl3L3Ra8N/nJSzgEVwSGmeRam0=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104174344-ae5266e77639 h1:K/1yIkqAWKGtLEtJ0PibijIdpo5YATL/9IUVXFoEglM=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104174344-ae5266e77639/go.mod h1:osh0jQyNqYDM27tbxQo6HeUEXgLq9XUewKzNhUXadog=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
|
||||
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=
|
||||
|
||||
@@ -25,14 +25,17 @@
|
||||
"invokes": [
|
||||
"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_placement"
|
||||
"mesh-delivery-assigner.assigner_read_assignments"
|
||||
],
|
||||
"consumes": [
|
||||
"mesh-delivery-indexer.views.modules",
|
||||
"mesh-delivery-indexer.views.manifests",
|
||||
"mesh-delivery-assigner.views.placement"
|
||||
"mesh-delivery-assigner.views.assignments",
|
||||
"mesh-catalog.seat-definition-changed",
|
||||
"mesh-catalog.seat-definition-removed"
|
||||
],
|
||||
"state": [
|
||||
"servers",
|
||||
@@ -101,7 +104,9 @@
|
||||
"claude_code_judge",
|
||||
"claude_code_judged",
|
||||
"claude_code_first_views",
|
||||
"claude_code_first_view_again"
|
||||
"claude_code_first_view_again",
|
||||
"claude_code_seat_list",
|
||||
"claude_code_seat_list_again"
|
||||
],
|
||||
"data": {
|
||||
"own": [
|
||||
|
||||
@@ -3,7 +3,7 @@ module claude-licence-manager
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
|
||||
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
|
||||
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=
|
||||
|
||||
@@ -2,7 +2,7 @@ module clipmenu
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
|
||||
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
||||
|
||||
require (
|
||||
github.com/klauspost/compress v1.20.1 // indirect
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
|
||||
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=
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ module cups
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
|
||||
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
||||
|
||||
require (
|
||||
github.com/klauspost/compress v1.20.1 // indirect
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
|
||||
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=
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ module dbus
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
|
||||
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
||||
github.com/godbus/dbus/v5 v5.1.0
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
|
||||
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.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
|
||||
|
||||
@@ -35,7 +35,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/channel"
|
||||
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,19 +60,19 @@ 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 channel.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 {
|
||||
channel.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 channel.Message `json:"message"`
|
||||
On map[string]int `json:"on"`
|
||||
Sent time.Time `json:"sent"`
|
||||
Message oc.Message `json:"message"`
|
||||
On map[string]int `json:"on"`
|
||||
Sent time.Time `json:"sent"`
|
||||
}
|
||||
|
||||
// Store keeps what is shown, by the router's handle.
|
||||
@@ -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 channel.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 := channel.Failed{Handle: m.Handle, Why: "not shown: " + why, At: d.Now()}
|
||||
return d.Publish(channel.IntakeSubject(channel.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] == channel.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 := channel.Failed{Handle: m.Handle, Why: why, At: d.Now()}
|
||||
if err := d.Publish(channel.IntakeSubject(channel.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 channel.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, channel.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, channel.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 := channel.Choice{ID: id, Ticket: ticket, Handle: handle, At: d.Now(),
|
||||
Sender: channel.Sender{Identity: machine, Display: machine, Authenticated: false}}
|
||||
if err := d.Publish(channel.IntakeSubject(channel.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, channel.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: channel.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(channel.IntakeSubject(channel.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
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/channel"
|
||||
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 channel.Message) {
|
||||
func take(t *testing.T, d *Desk, verb string, m oc.Message) {
|
||||
t.Helper()
|
||||
raw, _ := json.Marshal(m)
|
||||
if err := d.Take(channel.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 channel.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, channel.Show, channel.Message{Handle: "m1", Title: "3 failed services", Body: "Needs you: <mend> them.",
|
||||
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t1"}}})
|
||||
take(t, d, occ.Show, oc.Message{Handle: "m1", Title: "3 failed services", Body: "Needs you: <mend> 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[channel.IntakeSubject(channel.Chosen, Kind)]
|
||||
var c channel.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[channel.IntakeSubject(channel.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, channel.Send, channel.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, channel.Show, channel.Message{Handle: "m1", Title: "q", Body: "The summary.", Note: "Asked by mesh-knowledge at 16:05.",
|
||||
Buttons: []channel.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, channel.Show, channel.Message{Handle: "m1", Title: "q", Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t1"}}})
|
||||
take(t, d, channel.Edit, channel.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, channel.Show, channel.Message{Handle: "open", Title: "2 failed services on shanks",
|
||||
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
|
||||
take(t, d, channel.Show, channel.Message{Handle: "silenced", Title: "Agent-home shrank",
|
||||
Buttons: []channel.Button{{Label: "Silence for a week", Ticket: "t-silenced"}}})
|
||||
take(t, d, channel.Edit, channel.Message{Handle: "silenced", Title: "Silenced until Saturday: Agent-home shrank", Silent: true})
|
||||
take(t, d, channel.Show, channel.Message{Handle: "resolved", Title: "Questions for you not delivered",
|
||||
Buttons: []channel.Button{{Label: "Details", Ticket: "t-resolved"}}})
|
||||
take(t, d, channel.Edit, channel.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[channel.IntakeSubject(channel.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[channel.IntakeSubject(channel.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, channel.Show, channel.Message{Handle: "open", Title: "2 failed services on shanks",
|
||||
Buttons: []channel.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[channel.IntakeSubject(channel.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[channel.IntakeSubject(channel.StandingWhat, Kind)])
|
||||
if n := d.Started("e-start", "laptop"); n != 0 || len(said[channel.IntakeSubject(channel.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, channel.Send, channel.Message{Handle: "m1", Title: "x"})
|
||||
if f := said[channel.IntakeSubject(channel.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, channel.Send, channel.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 := channel.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true}
|
||||
take(t, d, channel.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, channel.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[channel.IntakeSubject(channel.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, channel.Send, channel.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, channel.Send, channel.Message{Handle: "m1", Title: "2 failed services on shanks"})
|
||||
take(t, d, channel.Send, channel.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, channel.Show, channel.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks", Urgent: true,
|
||||
Buttons: []channel.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(channel.Message{Handle: "m9", Title: "A new build cannot be delivered", Urgent: true})
|
||||
if err := d.Take(channel.ChannelSubject(channel.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(channel.Message{Handle: "m9", Title: "Cleared: a new build can be delivered"})
|
||||
if err := d.Take(channel.ChannelSubject(channel.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, channel.Send, channel.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, channel.Send, channel.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, channel.Send, channel.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, channel.Send, channel.Message{Handle: "m1", Title: "Now urgent: 2 failed services on shanks"})
|
||||
if len(said[channel.IntakeSubject(channel.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
|
||||
|
||||
@@ -2,7 +2,7 @@ module deskchannel
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
|
||||
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
||||
|
||||
require (
|
||||
github.com/klauspost/compress v1.20.1 // indirect
|
||||
@@ -12,3 +12,8 @@ require (
|
||||
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
|
||||
)
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM=
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f h1:jDsfDMd48MXY9Zershc3eRxnhmx2sSpUfQkLq5jAIds=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f/go.mod h1:08OomDXdwVN8/87nyDtv5R3rH/6LoP8SFFXz30bTYCM=
|
||||
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=
|
||||
|
||||
@@ -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 `<node>.<module>` per module placed on this node that declares data items, as the
|
||||
// (engineDataBucket), one key `<node>.<module>` 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user