Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52d22f07d7 | ||
|
|
8f6a2b1f82 | ||
|
|
a0fbd6bcb5 | ||
|
|
5ce46cd197 | ||
|
|
8943a290f5 | ||
|
|
537e2ef31f | ||
|
|
545a172bbf | ||
|
|
391095ccb9 | ||
|
|
8536021f82 | ||
|
|
23307935b9 | ||
|
|
d5d6fd7412 | ||
|
|
2e69099354 | ||
|
|
5c65b1f3dc | ||
|
|
2167b95a36 | ||
|
|
4f074e87c7 | ||
|
|
d09cbf1523 | ||
|
|
ac6712cbb5 | ||
|
|
1968aba6e0 | ||
|
|
5b32342ac2 | ||
|
|
6fb0ceeef8 | ||
|
|
c651161787 | ||
|
|
ee216e4e63 | ||
|
|
b67b5af5e6 | ||
|
|
d700ceca20 | ||
|
|
3a1017f559 | ||
|
|
a17d0c8e93 | ||
|
|
9fa6710296 | ||
|
|
05f849979c | ||
|
|
4dee77df23 | ||
|
|
8a79877a15 | ||
|
|
2139e83a8a | ||
|
|
e7ca8f7af1 | ||
|
|
eb79996336 | ||
|
|
1a271a1fb1 | ||
|
|
30d04f6bef | ||
|
|
5ff337e0dc | ||
|
|
2fabbe556e | ||
|
|
89b9212c4d | ||
|
|
40da368ee3 | ||
|
|
865f3b6455 | ||
|
|
03524ecd13 | ||
|
|
15101c9ce6 | ||
|
|
f910b0ceb8 | ||
|
|
91c3e954ae | ||
|
|
545c72bb59 | ||
|
|
7afcd0282d | ||
|
|
75156fce00 | ||
|
|
16d7b1898d | ||
|
|
f04465537b | ||
|
|
e870a7f65f | ||
|
|
e8a59171c7 | ||
|
|
ebb25757a2 | ||
|
|
6a70735489 | ||
|
|
ae6f7d5b54 |
+308
@@ -29,3 +29,311 @@ 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
|
||||
/modules/mesh-build-go/mesh-build-go
|
||||
/modules/mesh-delivery-gate/mesh-delivery-gate
|
||||
|
||||
# A binary built in place, named like its module directory or a cmd directory, in the module root or in that cmd directory
|
||||
# (issue 1921, try speed S13), for every module; the lint step of every check refuses one a change adds, a module added
|
||||
# later included.
|
||||
/modules/adwaita/adwaita
|
||||
/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/asus-zephyrus-g14
|
||||
/modules/asus-zephyrus-g14/zephyrus
|
||||
/modules/asus-zephyrus-g14/cmd/zephyrus/zephyrus
|
||||
/modules/audit-logger/audit-logger
|
||||
/modules/avahi/avahi
|
||||
/modules/avahi/avahi-tools
|
||||
/modules/avahi/cmd/avahi-tools/avahi-tools
|
||||
/modules/baserow/baserow
|
||||
/modules/blueman/blueman
|
||||
/modules/blueman/blueman-tools
|
||||
/modules/blueman/cmd/blueman-tools/blueman-tools
|
||||
/modules/bluetooth/bluetooth
|
||||
/modules/bluetooth/bluetooth-tools
|
||||
/modules/bluetooth/cmd/bluetooth-tools/bluetooth-tools
|
||||
/modules/brother/brother
|
||||
/modules/brother/cmd/brother/brother
|
||||
/modules/buildah/buildah
|
||||
/modules/ca-trust/ca-trust
|
||||
/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
|
||||
/modules/clipmenu/clipmenu-tools
|
||||
/modules/clipmenu/cmd/clipmenu-tools/clipmenu-tools
|
||||
/modules/confluence/confluence
|
||||
/modules/cups/cups
|
||||
/modules/cups/cups-tools
|
||||
/modules/cups/cmd/cups-tools/cups-tools
|
||||
/modules/dbus/dbus
|
||||
/modules/dbus/dbus-tools
|
||||
/modules/dbus/cmd/dbus-tools/dbus-tools
|
||||
/modules/desk-channel/desk-channel
|
||||
/modules/desk-channel/cmd/desk-channel/desk-channel
|
||||
/modules/dev-tools/dev-tools
|
||||
/modules/disk-load/disk-load
|
||||
/modules/disk-load/cmd/disk-load/disk-load
|
||||
/modules/distribution/distribution
|
||||
/modules/dmenu/dmenu
|
||||
/modules/dmenu/dmenu-tools
|
||||
/modules/dmenu/cmd/dmenu-tools/dmenu-tools
|
||||
/modules/dnsmasq/dnsmasq
|
||||
/modules/dnsmasq/dnsmasq-tools
|
||||
/modules/dnsmasq/cmd/dnsmasq-tools/dnsmasq-tools
|
||||
/modules/docker/docker
|
||||
/modules/docker/docker-tools
|
||||
/modules/docker/cmd/docker-tools/docker-tools
|
||||
/modules/docker-compose/docker-compose
|
||||
/modules/docker-compose/docker-compose-tools
|
||||
/modules/docker-compose/cmd/docker-compose-tools/docker-compose-tools
|
||||
/modules/dunst/dunst
|
||||
/modules/dunst/dunst-tools
|
||||
/modules/dunst/cmd/dunst-tools/dunst-tools
|
||||
/modules/fail2ban/fail2ban
|
||||
/modules/fail2ban/fail2ban-tools
|
||||
/modules/fail2ban/cmd/fail2ban-tools/fail2ban-tools
|
||||
/modules/feh/feh
|
||||
/modules/feh/feh-tools
|
||||
/modules/feh/cmd/feh-tools/feh-tools
|
||||
/modules/flatpak/flatpak
|
||||
/modules/flatpak/flatpak-tools
|
||||
/modules/flatpak/cmd/flatpak-tools/flatpak-tools
|
||||
/modules/fonts/fonts
|
||||
/modules/fonts/fonts-tools
|
||||
/modules/fonts/cmd/fonts-tools/fonts-tools
|
||||
/modules/forticlient/forticlient
|
||||
/modules/forticlient/forticlient-tools
|
||||
/modules/forticlient/cmd/forticlient-tools/forticlient-tools
|
||||
/modules/gitea/gitea
|
||||
/modules/gitea/agent-teams
|
||||
/modules/gitea/cmd/agent-teams/agent-teams
|
||||
/modules/gitea/agent-token
|
||||
/modules/gitea/cmd/agent-token/agent-token
|
||||
/modules/gitea/forge
|
||||
/modules/gitea/cmd/forge/forge
|
||||
/modules/gitea/forge-account-provider
|
||||
/modules/gitea/cmd/forge-account-provider/forge-account-provider
|
||||
/modules/gitea/npm-registry
|
||||
/modules/gitea/cmd/npm-registry/npm-registry
|
||||
/modules/gitea/repositories
|
||||
/modules/gitea/cmd/repositories/repositories
|
||||
/modules/gitlab/gitlab
|
||||
/modules/gnome-keyring/gnome-keyring
|
||||
/modules/gnome-keyring/gnome-keyring-tools
|
||||
/modules/gnome-keyring/cmd/gnome-keyring-tools/gnome-keyring-tools
|
||||
/modules/go/go
|
||||
/modules/grafana/grafana
|
||||
/modules/hello-web/hello-web
|
||||
/modules/home-assistant/home-assistant
|
||||
/modules/hostname/hostname
|
||||
/modules/hostname/hostname-tools
|
||||
/modules/hostname/cmd/hostname-tools/hostname-tools
|
||||
/modules/i3/i3
|
||||
/modules/i3/i3-tools
|
||||
/modules/i3/cmd/i3-tools/i3-tools
|
||||
/modules/i3status-rust/i3status-rust
|
||||
/modules/i3status-rust/i3status-rust-tools
|
||||
/modules/i3status-rust/cmd/i3status-rust-tools/i3status-rust-tools
|
||||
/modules/icecast/icecast
|
||||
/modules/influxdb/influxdb
|
||||
/modules/invoicing/invoicing
|
||||
/modules/jira/jira
|
||||
/modules/kernel/kernel
|
||||
/modules/kernel/kernel-tools
|
||||
/modules/kernel/cmd/kernel-tools/kernel-tools
|
||||
/modules/keycloak/keycloak
|
||||
/modules/keycloak/keycloak-provider
|
||||
/modules/keycloak/cmd/keycloak-provider/keycloak-provider
|
||||
/modules/lab/lab
|
||||
/modules/lemurs/lemurs
|
||||
/modules/lemurs/lemurs-tools
|
||||
/modules/lemurs/cmd/lemurs-tools/lemurs-tools
|
||||
/modules/letta/letta
|
||||
/modules/localization/localization
|
||||
/modules/localization/localization-tools
|
||||
/modules/localization/cmd/localization-tools/localization-tools
|
||||
/modules/local-model-consumer/local-model-consumer
|
||||
/modules/logrotate/logrotate
|
||||
/modules/logrotate/logrotate-tools
|
||||
/modules/logrotate/cmd/logrotate-tools/logrotate-tools
|
||||
/modules/mailu/mailu
|
||||
/modules/mailu/automx
|
||||
/modules/mailu/cmd/automx/automx
|
||||
/modules/marrytts/marrytts
|
||||
/modules/matrix/matrix
|
||||
/modules/memory-pressure/memory-pressure
|
||||
/modules/memory-pressure/cmd/memory-pressure/memory-pressure
|
||||
/modules/mesh-build-go/mesh-build-go
|
||||
/modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go
|
||||
/modules/mesh-build-image/mesh-build-image
|
||||
/modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image
|
||||
/modules/mesh-build-node/mesh-build-node
|
||||
/modules/mesh-build-node/cmd/mesh-build-node/mesh-build-node
|
||||
/modules/mesh-catalog/mesh-catalog
|
||||
/modules/mesh-cli/mesh-cli
|
||||
/modules/mesh-cli/cmd/mesh-cli/mesh-cli
|
||||
/modules/mesh-config/mesh-config
|
||||
/modules/mesh-config/cmd/mesh-config/mesh-config
|
||||
/modules/mesh-delivery-gate/mesh-delivery-gate
|
||||
/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/mesh-delivery-gate
|
||||
/modules/mesh-files/mesh-files
|
||||
/modules/mesh-files/cmd/mesh-files/mesh-files
|
||||
/modules/mesh-issues/mesh-issues
|
||||
/modules/mesh-issues/cmd/mesh-issues/mesh-issues
|
||||
/modules/mesh-knowledge/mesh-knowledge
|
||||
/modules/mesh-knowledge/cmd/mesh-knowledge/mesh-knowledge
|
||||
/modules/mesh-mail/mesh-mail
|
||||
/modules/mesh-mail/cmd/mesh-mail/mesh-mail
|
||||
/modules/mesh-mail-notifier/mesh-mail-notifier
|
||||
/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/mesh-mail-notifier
|
||||
/modules/mesh-mail-reader/mesh-mail-reader
|
||||
/modules/mesh-mail-reader/cmd/mesh-mail-reader/mesh-mail-reader
|
||||
/modules/mesh-mail-web/mesh-mail-web
|
||||
/modules/mesh-mail-web/cmd/mesh-mail-web/mesh-mail-web
|
||||
/modules/mesh-provisioner/mesh-provisioner
|
||||
/modules/mesh-provisioner/cmd/mesh-provisioner/mesh-provisioner
|
||||
/modules/mesh-provisioner-client/mesh-provisioner-client
|
||||
/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/mesh-provisioner-client
|
||||
/modules/mesh-reads/mesh-reads
|
||||
/modules/mesh-reads/cmd/mesh-reads/mesh-reads
|
||||
/modules/mesh-vault/mesh-vault
|
||||
/modules/mesh-vault/cmd/mesh-vault/mesh-vault
|
||||
/modules/mesh-watcher/mesh-watcher
|
||||
/modules/mesh-watcher/cmd/mesh-watcher/mesh-watcher
|
||||
/modules/messenger/messenger
|
||||
/modules/messenger/cmd/messenger/messenger
|
||||
/modules/minio/minio
|
||||
/modules/minio/object-store
|
||||
/modules/minio/cmd/object-store/object-store
|
||||
/modules/model-usage/model-usage
|
||||
/modules/mongodb/mongodb
|
||||
/modules/mosquitto/mosquitto
|
||||
/modules/mounts/mounts
|
||||
/modules/mounts/cmd/mounts/mounts
|
||||
/modules/mssql/mssql
|
||||
/modules/n8n/n8n
|
||||
/modules/nats/nats
|
||||
/modules/nats/mesh-nats-snapshot
|
||||
/modules/nats/cmd/mesh-nats-snapshot/mesh-nats-snapshot
|
||||
/modules/nats/nats-tools
|
||||
/modules/nats/cmd/nats-tools/nats-tools
|
||||
/modules/netcheck/netcheck
|
||||
/modules/netcheck/cmd/netcheck/netcheck
|
||||
/modules/networkmanager/networkmanager
|
||||
/modules/networkmanager/uplink-tools
|
||||
/modules/networkmanager/cmd/uplink-tools/uplink-tools
|
||||
/modules/nextcloud/nextcloud
|
||||
/modules/nextcloud-client/nextcloud-client
|
||||
/modules/nextcloud-client/nextcloud-client-tools
|
||||
/modules/nextcloud-client/cmd/nextcloud-client-tools/nextcloud-client-tools
|
||||
/modules/nfs-server/nfs-server
|
||||
/modules/nfs-server/cmd/nfs-server/nfs-server
|
||||
/modules/nftables/nftables
|
||||
/modules/nm-applet/nm-applet
|
||||
/modules/nm-applet/nm-applet-tools
|
||||
/modules/nm-applet/cmd/nm-applet-tools/nm-applet-tools
|
||||
/modules/node-env/node-env
|
||||
/modules/nodejs/nodejs
|
||||
/modules/nodered/nodered
|
||||
/modules/ollama/ollama
|
||||
/modules/only-office/only-office
|
||||
/modules/only-office/cmd/only-office/only-office
|
||||
/modules/openai-consumer/openai-consumer
|
||||
/modules/openrazer/openrazer
|
||||
/modules/openrazer/openrazer-tools
|
||||
/modules/openrazer/cmd/openrazer-tools/openrazer-tools
|
||||
/modules/pacman/pacman
|
||||
/modules/pacman/pacman-tools
|
||||
/modules/pacman/cmd/pacman-tools/pacman-tools
|
||||
/modules/photos-eef/photos-eef
|
||||
/modules/photos-filip/photos-filip
|
||||
/modules/picom/picom
|
||||
/modules/picom/picom-tools
|
||||
/modules/picom/cmd/picom-tools/picom-tools
|
||||
/modules/polychromatic/polychromatic
|
||||
/modules/polychromatic/polychromatic-tools
|
||||
/modules/polychromatic/cmd/polychromatic-tools/polychromatic-tools
|
||||
/modules/postgres/postgres
|
||||
/modules/postgres/postgres-provider
|
||||
/modules/postgres/cmd/postgres-provider/postgres-provider
|
||||
/modules/power/power
|
||||
/modules/power/cmd/power/power
|
||||
/modules/powerlevel10k/powerlevel10k
|
||||
/modules/records/records
|
||||
/modules/records/cmd/records/records
|
||||
/modules/redis/redis
|
||||
/modules/restic/restic
|
||||
/modules/restic/restic-backups
|
||||
/modules/restic/cmd/restic-backups/restic-backups
|
||||
/modules/rofi/rofi
|
||||
/modules/rofi/rofi-tools
|
||||
/modules/rofi/cmd/rofi-tools/rofi-tools
|
||||
/modules/route-adapter/route-adapter
|
||||
/modules/route-adapter/route-provider
|
||||
/modules/route-adapter/cmd/route-provider/route-provider
|
||||
/modules/screen-lock/screen-lock
|
||||
/modules/screen-lock/screen-lock-tools
|
||||
/modules/screen-lock/cmd/screen-lock-tools/screen-lock-tools
|
||||
/modules/searxng/searxng
|
||||
/modules/sensors/sensors
|
||||
/modules/sensors/cmd/sensors/sensors
|
||||
/modules/showcase/showcase
|
||||
/modules/slack/slack
|
||||
/modules/slack/slack-tools
|
||||
/modules/slack/cmd/slack-tools/slack-tools
|
||||
/modules/snapd/snapd
|
||||
/modules/snapd/snapd-tools
|
||||
/modules/snapd/cmd/snapd-tools/snapd-tools
|
||||
/modules/ssh-client/ssh-client
|
||||
/modules/ssh-client/ssh-client-tools
|
||||
/modules/ssh-client/cmd/ssh-client-tools/ssh-client-tools
|
||||
/modules/sshd/sshd
|
||||
/modules/step-ca/step-ca
|
||||
/modules/sudo/sudo
|
||||
/modules/sudo/sudo-tools
|
||||
/modules/sudo/cmd/sudo-tools/sudo-tools
|
||||
/modules/supabase/supabase
|
||||
/modules/systemd/systemd
|
||||
/modules/systemd/systemd-tools
|
||||
/modules/systemd/cmd/systemd-tools/systemd-tools
|
||||
/modules/systemd-networkd/systemd-networkd
|
||||
/modules/systemd-networkd/uplink-tools
|
||||
/modules/systemd-networkd/cmd/uplink-tools/uplink-tools
|
||||
/modules/systemd-resolved/systemd-resolved
|
||||
/modules/systemd-resolved/resolver-tools
|
||||
/modules/systemd-resolved/cmd/resolver-tools/resolver-tools
|
||||
/modules/telegram/telegram
|
||||
/modules/telegram/cmd/telegram/telegram
|
||||
/modules/time-sync/time-sync
|
||||
/modules/time-sync/time-sync-tools
|
||||
/modules/time-sync/cmd/time-sync-tools/time-sync-tools
|
||||
/modules/triggerhappy/triggerhappy
|
||||
/modules/triggerhappy/triggerhappy-tools
|
||||
/modules/triggerhappy/cmd/triggerhappy-tools/triggerhappy-tools
|
||||
/modules/umami/umami
|
||||
/modules/unifi/unifi
|
||||
/modules/website/website
|
||||
/modules/xclip/xclip
|
||||
/modules/xclip/xclip-tools
|
||||
/modules/xclip/cmd/xclip-tools/xclip-tools
|
||||
/modules/xdg/xdg
|
||||
/modules/xdg/xdg-tools
|
||||
/modules/xdg/cmd/xdg-tools/xdg-tools
|
||||
/modules/xorg/xorg
|
||||
/modules/xorg/cmd/xorg/xorg
|
||||
/modules/xterm/xterm
|
||||
/modules/xterm/xterm-tools
|
||||
/modules/xterm/cmd/xterm-tools/xterm-tools
|
||||
/modules/zfs/zfs
|
||||
/modules/zfs/zfs-tools
|
||||
/modules/zfs/cmd/zfs-tools/zfs-tools
|
||||
/modules/zsh/zsh
|
||||
/modules/zsh-autosuggestions/zsh-autosuggestions
|
||||
/modules/zsh-syntax-highlighting/zsh-syntax-highlighting
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -251,7 +251,8 @@ func main() {
|
||||
}
|
||||
var grown []string
|
||||
if baseDir == "" {
|
||||
fmt.Println("NOT COMPARED: no base branch's lists were given, so additions to them were not judged")
|
||||
fmt.Fprintln(os.Stderr, "NOT COMPARED: no base branch's lists were given (-base <dir>), so additions to them were not judged; that is a failure, not a pass (issue 1295)")
|
||||
os.Exit(2)
|
||||
} else {
|
||||
baseShared, _, err1 := list(filepath.Join(baseDir, "shared"))
|
||||
baseKnown, _, err2 := list(filepath.Join(baseDir, "known"))
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -32,8 +32,8 @@ func TestNoModuleNamesAnothersState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// And neither list gains a line the trunk's copies at the merge base do not hold, unless the line says why after `# allow:`. Skipped,
|
||||
// and said, where no trunk is beside.
|
||||
// And neither list gains a line the trunk's copies at the merge base do not hold, unless the line says why after `# allow:`. Fails, saying
|
||||
// NOT COMPARED, where no trunk is beside (issue 1295).
|
||||
func TestTheListsOfPathsHoldNoLineTheTrunksDoNot(t *testing.T) {
|
||||
sharedPath, okShared := trunk.Copy(t, "checks/paths/shared")
|
||||
knownPath, okKnown := trunk.Copy(t, "checks/paths/known")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
+14
-18
@@ -3,14 +3,14 @@
|
||||
//
|
||||
// The checker fetches the change's own repository at its base commit beside the change, as it fetches a sibling, and says where
|
||||
// in MESH_CHECK_BESIDE; the copy is under the repository's name (the same convention the other repositories' tests use). With
|
||||
// MESH_CHECK_BESIDE unset, or set without the catalogue's copy in it, the comparison is skipped and the test says NOT COMPARED
|
||||
// WITH THE TRUNK, naming why: the checker that runs before R4g's delivery places siblings but not the change's own repository,
|
||||
// so R4g's own check cannot hold the copy (issue 1295 notes that a missing copy becomes a failure once that checker is gone). A trunk that holds no such file had
|
||||
// MESH_CHECK_BESIDE unset, or set without the catalogue's copy in it, the test fails saying NOT COMPARED WITH THE TRUNK and
|
||||
// why: the checker places the change's own repository since R4g (issue 1295), so a missing copy is a fault, never a pass. A trunk that holds no such file had
|
||||
// none: the list's first copy, which nothing was added to.
|
||||
package trunk
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -23,13 +23,11 @@ const Repository = "mesh-catalog"
|
||||
const BesideEnv = "MESH_CHECK_BESIDE"
|
||||
|
||||
// Copy is the trunk's copy of the file at rel (a path from the catalogue's root), written to a file of the test's own: its path,
|
||||
// and whether the trunk holds one (false: its first copy). It skips the test, saying so, when no copy of the catalogue is beside.
|
||||
// and whether the trunk holds one (false: its first copy). It fails the test when no copy of the catalogue is beside.
|
||||
func Copy(t testing.TB, rel string) (string, bool) {
|
||||
t.Helper()
|
||||
raw, found, skip, err := Read(rel)
|
||||
raw, found, err := Read(rel)
|
||||
switch {
|
||||
case skip != "":
|
||||
t.Skip(skip)
|
||||
case err != nil:
|
||||
t.Fatal(err)
|
||||
case !found:
|
||||
@@ -43,27 +41,25 @@ func Copy(t testing.TB, rel string) (string, bool) {
|
||||
return path, true
|
||||
}
|
||||
|
||||
// Read is the trunk's copy of rel and whether the trunk holds one. skip is the words to skip the comparison with, when no trunk is
|
||||
// named; err is a trunk named and not there, or unreadable.
|
||||
func Read(rel string) (content []byte, found bool, skip string, err error) {
|
||||
// Read is the trunk's copy of rel and whether the trunk holds one. err is a trunk not beside the check (MESH_CHECK_BESIDE unset,
|
||||
// or without the catalogue's directory), or a copy unreadable: a comparison that cannot be made is never a pass.
|
||||
func Read(rel string) (content []byte, found bool, err error) {
|
||||
beside := os.Getenv(BesideEnv)
|
||||
if beside == "" {
|
||||
return nil, false, "NOT COMPARED WITH THE TRUNK: " + BesideEnv + " is not set, so the trunk's copy of " + rel +
|
||||
" is not beside this run; the checker places it", nil
|
||||
return nil, false, fmt.Errorf("NOT COMPARED WITH THE TRUNK: %s is not set, so the trunk's copy of %s is not beside this run; the checker places the change's own repository there (issue 1295)", BesideEnv, rel)
|
||||
}
|
||||
dir := filepath.Join(beside, Repository)
|
||||
if _, err := os.Stat(dir); errors.Is(err, os.ErrNotExist) {
|
||||
return nil, false, "NOT COMPARED WITH THE TRUNK: " + Repository + " is not beside this check in " + BesideEnv + "=" + beside +
|
||||
", so " + rel + " is not judged against the trunk's copy (the checker before R4g places no copy of the change's own repository)", nil
|
||||
return nil, false, fmt.Errorf("NOT COMPARED WITH THE TRUNK: %s is not beside this check in %s=%s, so %s is not judged against the trunk's copy; the checker places the change's own repository there at its trunk (issue 1295)", Repository, BesideEnv, beside, rel)
|
||||
} else if err != nil {
|
||||
return nil, false, "", err
|
||||
return nil, false, err
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, rel))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, false, "", nil
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, "", err
|
||||
return nil, false, err
|
||||
}
|
||||
return raw, true, "", nil
|
||||
return raw, true, nil
|
||||
}
|
||||
|
||||
+14
-14
@@ -17,27 +17,27 @@ func TestTheCloneBesideTheCheckIsTheTrunk(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(BesideEnv, beside)
|
||||
raw, found, skip, err := Read("checks/x/known")
|
||||
if err != nil || skip != "" || !found || string(raw) != "a\nb\n" {
|
||||
t.Fatalf("read %q found %v skip %q err %v", raw, found, skip, err)
|
||||
raw, found, err := Read("checks/x/known")
|
||||
if err != nil || !found || string(raw) != "a\nb\n" {
|
||||
t.Fatalf("read %q found %v err %v", raw, found, err)
|
||||
}
|
||||
if _, found, skip, err := Read("checks/x/absent"); err != nil || skip != "" || found {
|
||||
t.Fatalf("a file the trunk does not hold: found %v skip %q err %v; wanted its first copy", found, skip, err)
|
||||
if _, found, err := Read("checks/x/absent"); err != nil || found {
|
||||
t.Fatalf("a file the trunk does not hold: found %v err %v; wanted its first copy", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithNothingBesideTheComparisonIsSkippedAndSaidNotMade(t *testing.T) {
|
||||
func TestWithNothingBesideTheComparisonFailsAndSaysNotMade(t *testing.T) {
|
||||
t.Setenv(BesideEnv, "")
|
||||
_, found, skip, err := Read("checks/x/known")
|
||||
if found || err != nil || len(skip) < 12 || skip[:12] != "NOT COMPARED" {
|
||||
t.Fatalf("found %v, skip %q, err %v; wanted NOT COMPARED", found, skip, err)
|
||||
_, found, err := Read("checks/x/known")
|
||||
if found || err == nil || !strings.Contains(err.Error(), "NOT COMPARED") {
|
||||
t.Fatalf("found %v, err %v; wanted a failure saying NOT COMPARED", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACloneBesideWithoutTheCatalogueIsSkippedAndSaidNotMade(t *testing.T) {
|
||||
t.Setenv(BesideEnv, t.TempDir()) // the siblings are there, the catalogue's own base is not (the checker before R4g)
|
||||
_, found, skip, err := Read("checks/x/known")
|
||||
if found || err != nil || len(skip) < 12 || skip[:12] != "NOT COMPARED" || !strings.Contains(skip, "not beside") {
|
||||
t.Fatalf("found %v, skip %q, err %v; wanted NOT COMPARED naming the missing copy", found, skip, err)
|
||||
func TestACloneBesideWithoutTheCatalogueFailsAndSaysNotMade(t *testing.T) {
|
||||
t.Setenv(BesideEnv, t.TempDir()) // the siblings are there, the catalogue's own base is not
|
||||
_, found, err := Read("checks/x/known")
|
||||
if found || err == nil || !strings.Contains(err.Error(), "NOT COMPARED") || !strings.Contains(err.Error(), "not beside") {
|
||||
t.Fatalf("found %v, err %v; wanted a failure naming the missing copy", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -65,6 +65,7 @@ func configFromEnv(getenv func(string) string) (Config, error) {
|
||||
HostFile: getenv("MESH_FORGE_HOST_FILE"),
|
||||
OriginsFile: getenv("MESH_GITEA_ORIGINS_FILE"),
|
||||
Port: port,
|
||||
HostKeyFile: getenv("MESH_GITEA_HOST_KEY_FILE"), // optional
|
||||
Forge: forgehttp.Client{URL: strings.TrimRight(getenv("MESH_GITEA_URL"), "/"), User: getenv("MESH_GITEA_ADMIN_USER"),
|
||||
PasswordFile: getenv("MESH_GITEA_ADMIN_PASSWORD_FILE"), Timeout: 20 * time.Second},
|
||||
Runner: execRunner{},
|
||||
|
||||
@@ -44,10 +44,13 @@ type Config struct {
|
||||
// origins, one per line.
|
||||
HostFile, OriginsFile string
|
||||
// Port is where the forge's sshd is published on the mesh.
|
||||
Port int
|
||||
Forge forgehttp.Client
|
||||
Runner Runner
|
||||
Log func(format string, a ...any)
|
||||
Port int
|
||||
// HostKeyFile is the forge's own ed25519 host key (the .pub file in its data directory). It is read first; when it
|
||||
// is unset or unreadable the forge's sshd is asked with ssh-keyscan instead (issue 1939).
|
||||
HostKeyFile string
|
||||
Forge forgehttp.Client
|
||||
Runner Runner
|
||||
Log func(format string, a ...any)
|
||||
}
|
||||
|
||||
// Service answers the consumers of the provision `forge-account` (novox/hq ADR 0399, ADR 0400; issue 955): for each
|
||||
@@ -448,14 +451,30 @@ func (s *Service) httpsBase() (string, error) {
|
||||
return "", fmt.Errorf("%s names no https origin", s.cfg.OriginsFile)
|
||||
}
|
||||
|
||||
// scan reads the forge's ed25519 host key from its own sshd and writes it as the machines' known_hosts line.
|
||||
// scan gives the forge's ed25519 host key as the machines' known_hosts line. The key the forge itself holds is read
|
||||
// from its file first; only when that cannot be read is the forge's sshd asked over the network, and when that fails
|
||||
// too (the forge's container is restarting) the key read before is kept, as a host key does not change (issue 1939).
|
||||
func (s *Service) scan(ctx context.Context) (string, error) {
|
||||
host, err := s.hostName()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if s.cfg.HostKeyFile != "" {
|
||||
raw, ferr := os.ReadFile(s.cfg.HostKeyFile)
|
||||
if ferr == nil {
|
||||
if line, perr := knownHostLine("127.0.0.1 "+strings.TrimSpace(string(raw)), host, s.cfg.Port); perr == nil {
|
||||
return line, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
out, err := s.cfg.Runner.Run(ctx, "ssh-keyscan", "-t", "ed25519", "-p", fmt.Sprint(s.cfg.Port), "127.0.0.1")
|
||||
if err != nil {
|
||||
s.mu.Lock()
|
||||
kept := s.known
|
||||
s.mu.Unlock()
|
||||
if kept != "" {
|
||||
return kept, nil
|
||||
}
|
||||
return "", fmt.Errorf("the forge's ssh host key cannot be read: %w", err)
|
||||
}
|
||||
return knownHostLine(string(out), host, s.cfg.Port)
|
||||
|
||||
@@ -696,3 +696,42 @@ func TestNothingIsSaidForAPerNodeUserWithoutMeshKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The forge's own host key file is read first: no scan over the network is needed (issue 1939).
|
||||
func TestTheHostKeyIsReadFromTheForgesOwnFile(t *testing.T) {
|
||||
r := newRig(t)
|
||||
f := filepath.Join(t.TempDir(), "ssh_host_ed25519_key.pub")
|
||||
if err := os.WriteFile(f, []byte("ssh-ed25519 AAAAC3FILEKEY forge@host\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.svc.cfg.HostKeyFile = f
|
||||
r.runner.err = errors.New("sshd down")
|
||||
got, err := r.svc.scan(context.Background())
|
||||
if err != nil || got != "[novox.internal]:222 ssh-ed25519 AAAAC3FILEKEY" {
|
||||
t.Fatalf("scan = %q, %v", got, err)
|
||||
}
|
||||
if r.runner.args != nil {
|
||||
t.Errorf("the forge's sshd was asked: %v", r.runner.args)
|
||||
}
|
||||
}
|
||||
|
||||
// A file that cannot be read falls back to the forge's sshd.
|
||||
func TestAnUnreadableHostKeyFileFallsBackToTheScan(t *testing.T) {
|
||||
r := newRig(t)
|
||||
r.svc.cfg.HostKeyFile = filepath.Join(t.TempDir(), "missing.pub")
|
||||
got, err := r.svc.scan(context.Background())
|
||||
if err != nil || got != "[novox.internal]:222 ssh-ed25519 AAAAC3HOSTKEY" {
|
||||
t.Fatalf("scan = %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// While the forge restarts, a key read before is kept: the sshd is not up, the key has not changed.
|
||||
func TestARestartingForgeKeepsTheKeyReadBefore(t *testing.T) {
|
||||
r := newRig(t)
|
||||
r.svc.known = "[novox.internal]:222 ssh-ed25519 AAAAC3HOSTKEY"
|
||||
r.runner.err = errors.New("connection refused")
|
||||
got, err := r.svc.scan(context.Background())
|
||||
if err != nil || got != r.svc.known {
|
||||
t.Fatalf("scan = %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,7 +589,11 @@ if (gitea) {
|
||||
};
|
||||
// **The forge's events first, the polls as the safety net** (novox/hq issue 729): while verified deliveries arrive the
|
||||
// polls slow to every five minutes and announce only what no event said (Pace); otherwise they keep 30 s.
|
||||
await listenForForge(client);
|
||||
// **Not awaited** (novox/hq issue 1726): the receiver's set-up asks the forge's admin API, and the runtime kills a bundle
|
||||
// whose import has not ended in its 10 s handshake and never starts it again; a forge slow to answer while every bundle
|
||||
// of the node starts left gitea_commit_status, gitea_merge_pull_request and gitea_events served nowhere. Until it is
|
||||
// listening, Pace keeps the polls at 30 s, so nothing is missed meanwhile; listenForForge says its own failure.
|
||||
void listenForForge(client);
|
||||
// Main takes no push and only the tools' account merges, in every repository, kept and not only when a token is
|
||||
// minted: at the start and every ten minutes after, so a repository made later gets it too (issue 948, ADR 0398 §2).
|
||||
// Only while the setting keep-main-rules is true (mainrules.ts); false, the default, leaves every repository's rule as set.
|
||||
|
||||
@@ -537,6 +537,7 @@
|
||||
"MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret",
|
||||
"MESH_GITEA_ORIGINS_FILE": "${dir:mesh-state}/origins",
|
||||
"MESH_GITEA_SSH_PORT": "222",
|
||||
"MESH_GITEA_HOST_KEY_FILE": "${dir:data}/ssh/ssh_host_ed25519_key.pub",
|
||||
"MESH_FORGE_HOST_FILE": "/etc/gitea/forge-host"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,3 +109,53 @@ test("a bundle whose read of the checker's owed is granted late writes the lates
|
||||
assert.match(got.statuses[0], /"state":"success".*pass at 4/);
|
||||
assert.match(err, /mesh-delivery-checker\.owed could not be bound/, "the refusal is said");
|
||||
});
|
||||
|
||||
// **The bundle's import does not wait on the forge** (novox/hq issue 1726, 2026-10-24 12:37). The webhook receiver's set-up
|
||||
// asks the forge's admin API for its system webhook; awaited at the top of index.ts, a forge slow to answer (busy while
|
||||
// every bundle of the node starts) held the import past node-tools' 10 s handshake, the bundle was killed and never
|
||||
// started again, and gitea_commit_status, gitea_merge_pull_request and gitea_events were served nowhere: the forge
|
||||
// bridge's calls found no responders and no new pull request was announced. Here the forge stand-in takes every
|
||||
// request and never answers: the import resolves at once all the same, with the bundle's tools registered.
|
||||
test("importing the bundle resolves, its tools registered, while the forge never answers the webhook's set-up", async () => {
|
||||
const index = pathToFileURL(join(here, "..", "dist", "index.js")).href;
|
||||
const script = `
|
||||
const http = await import("node:http");
|
||||
const fs = await import("node:fs");
|
||||
const os = await import("node:os");
|
||||
const path = await import("node:path");
|
||||
const forge = http.createServer(() => {});
|
||||
await new Promise((r) => forge.listen(0, "127.0.0.1", r));
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gitea-1726-"));
|
||||
fs.writeFileSync(path.join(dir, "admin.password"), "pw");
|
||||
process.env.MESH_GITEA_URL = "http://127.0.0.1:" + forge.address().port;
|
||||
process.env.MESH_GITEA_STATE_DIR = dir;
|
||||
process.env.MESH_GITEA_ADMIN_PASSWORD_FILE = path.join(dir, "admin.password");
|
||||
process.env.MESH_GITEA_HOOK_PORT = "0";
|
||||
process.env.MESH_GITEA_HOOK_HOST = "127.0.0.1";
|
||||
const { useBroker } = await import("@novox/mesh-sdk/messaging");
|
||||
useBroker(() => ({
|
||||
subscribe: async () => () => {}, publish: async () => {}, request: async () => { throw new Error("no responders"); },
|
||||
handle: async () => () => {}, close: async () => {},
|
||||
}));
|
||||
await import(${JSON.stringify(index)});
|
||||
const { collectTools } = await import("@novox/mesh-sdk/tools");
|
||||
console.log(JSON.stringify({ imported: true, tools: collectTools().flatMap((r) => r.tools.map((t) => t.name)) }));
|
||||
process.exit(0);`;
|
||||
const env = { ...process.env, MESH_GITEA_TOKEN: "x", MESH_GITEA_ADMIN_USER: "admin", MESH_GITEA_HOOK_URL: "http://127.0.0.1:1" };
|
||||
for (const k of ["MESH_SERVED_MODULE", "MESH_GITEA_ADMIN_PASSWORD", "MESH_GITEA_CONFIG_FILE"]) delete env[k];
|
||||
const child = spawn(process.execPath, ["--input-type=module", "-e", script], { cwd: join(here, ".."), env, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let out = "";
|
||||
let err = "";
|
||||
child.stdout.on("data", (d) => (out += d));
|
||||
child.stderr.on("data", (d) => (err += d));
|
||||
const ended = new Promise<void>((r) => child.on("exit", () => r()));
|
||||
const timer = setTimeout(() => child.kill("SIGKILL"), 3000);
|
||||
await ended;
|
||||
clearTimeout(timer);
|
||||
const line = out.split("\n").find((l) => l.startsWith("{"));
|
||||
assert.ok(line, `the import did not resolve within 3 s while the forge did not answer; stdout: ${out.slice(0, 400)} stderr: ${err.slice(0, 600)}`);
|
||||
const got = JSON.parse(line!) as { tools: string[] };
|
||||
for (const t of ["gitea_events", "gitea_commit_status", "gitea_merge_pull_request"]) {
|
||||
assert.ok(got.tools.includes(t), `${t} is registered: ${got.tools}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -100,6 +100,9 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
|
||||
if err := m.room(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.Compose != nil {
|
||||
return m.compose(ctx, ask.Fetch, a)
|
||||
}
|
||||
return buildstep.RunAsk(ctx, ask, a)
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package main
|
||||
|
||||
// Stray build output (issue 1921, try speed S13): the lint step fails a change that adds an executable (ELF or Mach-O) or
|
||||
// a file over MaxAddedFile, naming each, unless the repository's .binaries-allowed lists its path. Build binaries were
|
||||
// committed three times in one night; the forge keeps them for ever.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
// MaxAddedFile is the largest file a change may add without .binaries-allowed naming it.
|
||||
const MaxAddedFile = 5 << 20
|
||||
|
||||
// BinariesAllowed is the file in a repository's root listing, one path per line ("#" starts a comment), the executables
|
||||
// and large files it keeps on purpose.
|
||||
const BinariesAllowed = ".binaries-allowed"
|
||||
|
||||
// executableMagic are the first bytes of an ELF and of each Mach-O form (32 and 64 bits, both byte orders, universal).
|
||||
var executableMagic = [][]byte{[]byte("\x7fELF"), {0xfe, 0xed, 0xfa, 0xce}, {0xfe, 0xed, 0xfa, 0xcf}, {0xce, 0xfa, 0xed, 0xfe},
|
||||
{0xcf, 0xfa, 0xed, 0xfe}, {0xca, 0xfe, 0xba, 0xbe}}
|
||||
|
||||
// addedBinaries is the lint finding of the files the merge adds to the trunk that are executables or over MaxAddedFile,
|
||||
// nil when there is none. The paths come from git (the merge commit against its first parent, the trunk); a tree with no
|
||||
// merge (no first parent) adds nothing to judge.
|
||||
func (m *Module) addedBinaries(ctx context.Context, run forgekey.Runner, tree string) []Failure {
|
||||
r := run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, "git", "-C", tree, "diff", "--name-only", "--no-renames", "--diff-filter=A", "HEAD^1", "HEAD")
|
||||
if r.Status != 0 || r.Err != "" {
|
||||
return nil
|
||||
}
|
||||
allowed := map[string]bool{}
|
||||
if raw, err := os.ReadFile(filepath.Join(tree, BinariesAllowed)); err == nil {
|
||||
scan := bufio.NewScanner(bytes.NewReader(raw))
|
||||
for scan.Scan() {
|
||||
if l := strings.TrimSpace(scan.Text()); l != "" && !strings.HasPrefix(l, "#") {
|
||||
allowed[filepath.ToSlash(l)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
var found []string
|
||||
for _, p := range strings.Split(r.Stdout, "\n") {
|
||||
if p = strings.TrimSpace(p); p == "" || allowed[p] {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(tree, filepath.FromSlash(p))
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
continue // narrowed away, or not a file
|
||||
}
|
||||
if info.Size() > MaxAddedFile {
|
||||
found = append(found, fmt.Sprintf("%s (%d bytes, over %d)", p, info.Size(), MaxAddedFile))
|
||||
continue
|
||||
}
|
||||
if isExecutable(path) {
|
||||
found = append(found, p+" (an executable)")
|
||||
}
|
||||
}
|
||||
if len(found) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.Strings(found)
|
||||
return []Failure{{Package: ".", Test: "binaries", Step: StepLint,
|
||||
Output: "the change adds build output or a large file (remove it, or list its path in " + BinariesAllowed + "): " + strings.Join(found, ", ")}}
|
||||
}
|
||||
|
||||
func isExecutable(path string) bool {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
head := make([]byte, 4)
|
||||
if n, _ := f.Read(head); n < 4 {
|
||||
return false
|
||||
}
|
||||
for _, magic := range executableMagic {
|
||||
if bytes.Equal(head, magic) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
// Issue 1921, try speed S13: the lint step fails a change that adds an executable (ELF or Mach-O) or a file over 5 MB,
|
||||
// naming it, unless .binaries-allowed in the repository's root lists its path; a file the change does not add is not
|
||||
// judged, and neither is an allowed one.
|
||||
func TestTheLintStepFailsAChangeThatAddsABinaryOrALargeFile(t *testing.T) {
|
||||
m, states, ws, _ := workspaceFor(t, "slow")
|
||||
tree := filepath.Join(m.Work, ws, "tree")
|
||||
write := func(name string, body []byte) {
|
||||
os.MkdirAll(filepath.Dir(filepath.Join(tree, name)), 0o755)
|
||||
if err := os.WriteFile(filepath.Join(tree, name), body, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("go.mod", []byte("module fixture/slow\n"))
|
||||
elf := append([]byte("\x7fELF"), make([]byte, 64)...)
|
||||
macho := append([]byte{0xcf, 0xfa, 0xed, 0xfe}, make([]byte, 64)...)
|
||||
write("modules/x/cmd/x/x", elf)
|
||||
write("modules/y/y", macho)
|
||||
write("big.tar", make([]byte, 5<<20+1))
|
||||
write("tools/allowed-tool", elf)
|
||||
write("old/binary", elf) // there before the change
|
||||
write("modules/x/doc.md", []byte("text\n"))
|
||||
write(".binaries-allowed", []byte("# kept on purpose\ntools/allowed-tool\n"))
|
||||
inner := m.Run
|
||||
m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
||||
if name == "git" && strings.Contains(strings.Join(args, " "), "--diff-filter=A") {
|
||||
return forgekey.Ran{Stdout: "modules/x/cmd/x/x\nmodules/y/y\nbig.tar\ntools/allowed-tool\nmodules/x/doc.md\n.binaries-allowed\n"}
|
||||
}
|
||||
return inner(ctx, env, name, args...)
|
||||
}
|
||||
res, st := runUnit(t, m, states, ws)
|
||||
if st.State != delivery.StepPassed {
|
||||
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
|
||||
}
|
||||
var found []Failure
|
||||
for _, f := range res.Failed {
|
||||
if f.Test == "binaries" {
|
||||
found = append(found, f)
|
||||
}
|
||||
}
|
||||
if res.Passed || len(found) != 1 || found[0].Step != StepLint {
|
||||
t.Fatalf("failed = %+v, want one lint finding of the binaries", res.Failed)
|
||||
}
|
||||
out := found[0].Output
|
||||
for _, want := range []string{"modules/x/cmd/x/x", "modules/y/y", "big.tar"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("the finding does not name %s: %s", want, out)
|
||||
}
|
||||
}
|
||||
for _, not := range []string{"allowed-tool", "old/binary", "doc.md"} {
|
||||
if strings.Contains(out, not) {
|
||||
t.Errorf("the finding names %s: %s", not, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// 1921/S11 (the lead's decision): a check compiles and runs with CGO_ENABLED=0, as a production build does, so it tests
|
||||
// what ships and the warm cache matches; the lint's go vet, a compile alone, writes the shared cache, while a go test that
|
||||
// runs a test binary reads it beneath the ask's own layer.
|
||||
func TestACheckRunsWithoutCgoAndOnlyItsCompileWritesTheSharedCache(t *testing.T) {
|
||||
m, states, ws, calls := workspaceFor(t, "failing")
|
||||
var seen []shutOff
|
||||
m.Run = throughBubblewrap(t, m.Run, &seen)
|
||||
if _, st := runUnit(t, m, states, ws); st.State != delivery.StepPassed {
|
||||
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
|
||||
}
|
||||
for _, c := range *calls {
|
||||
if strings.HasPrefix(c.line, "go ") && !slices.Contains(c.env, "CGO_ENABLED=0") {
|
||||
t.Errorf("%s ran without CGO_ENABLED=0: %v", c.line, c.env)
|
||||
}
|
||||
}
|
||||
vets, tests := 0, 0
|
||||
for _, s := range seen {
|
||||
switch {
|
||||
case strings.Contains(s.command, " vet "):
|
||||
vets++
|
||||
checkShutOff(t, m, s, true) // the shared caches bound read-write
|
||||
case strings.Contains(s.command, " test "):
|
||||
tests++
|
||||
checkShutOff(t, m, s, false)
|
||||
}
|
||||
}
|
||||
if vets == 0 || tests == 0 {
|
||||
t.Errorf("%d vets and %d tests ran shut off, want both", vets, tests)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package main
|
||||
|
||||
// **A compose ask runs mesh-sdk's composer at a commit** (novox/hq ADR 0482 clause 4, issues 1621 and 1622): the
|
||||
// assigner asks it, to compose a group with its mesh-sdk member's head, and to read the group's manifests with the
|
||||
// mesh-sdk the running controller and node-engine pin (ADR 0482 clause 5, issue 1623). The module fetches the commit
|
||||
// (its fetch step: the forge key and the network, none of the change's code), builds the composer package in the Go
|
||||
// module the ask names, in a run step shut off with nothing fetched, and keeps the program under its key
|
||||
// (delivery.ComposerKey), so each commit is built once on a node. It fills the request's manifests from the delivery
|
||||
// indexer's manifests state, by digest, and runs the program shut off: no network, none of the account, the request a
|
||||
// file of a workspace of its own. The answer is the composer's verdict whole (delivery.ComposeAnswer); the module
|
||||
// judges nothing of it.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/composer"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// ManifestsState is the delivery indexer's manifests by digest, which a compose ask's request names (the module reads
|
||||
// it, module.json reads).
|
||||
const ManifestsState = delivery.IndexerModule + ".manifests"
|
||||
|
||||
// composerLocks hold one mutex per composer key, so a composer two asks need at once is built once.
|
||||
var composerLocks sync.Map
|
||||
|
||||
// composerPath is where the composer of key is kept: the cache directory, beside Go's own caches.
|
||||
func (m *Module) composerPath(key string) string {
|
||||
return filepath.Join(m.Cache, "composers", key, "mesh-compose")
|
||||
}
|
||||
|
||||
// compose runs a compose ask: the composer of its commit, built once, over its request with the manifests filled.
|
||||
func (m *Module) compose(ctx context.Context, fetch buildstep.StepFunc, a delivery.StepAsk) (any, error) {
|
||||
job := *a.Compose
|
||||
repository, _ := job.Fetch["repository"].(string)
|
||||
commit, _ := job.Fetch["commit"].(string)
|
||||
installed, err := m.Probe(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := delivery.ComposerKey(repository, commit, job.Dir, job.Package, delivery.ToolchainUsed{Version: installed, Recipe: m.Recipe.Version()})
|
||||
exe, built, err := m.composerOf(ctx, fetch, job, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := composer.Decode(job.Request)
|
||||
if err != nil {
|
||||
return nil, buildstep.Refuse("the compose ask %s: %v", a.ID, err)
|
||||
}
|
||||
if err := composer.Fill(&req, m.manifestOf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
verdict, err := m.runComposer(ctx, exe, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return delivery.ComposeAnswer{Built: built, Key: key, Verdict: verdict}, nil
|
||||
}
|
||||
|
||||
// manifestOf is the manifest the indexer keeps under digest, as its repository holds it.
|
||||
func (m *Module) manifestOf(digest string) ([]byte, error) {
|
||||
if m.ReadManifest == nil {
|
||||
return nil, errors.New("this module reads no manifests state")
|
||||
}
|
||||
key, err := delivery.ManifestsKey(digest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := m.ReadManifest(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if raw == nil {
|
||||
return nil, fmt.Errorf("the indexer keeps no manifest of digest %s", digest)
|
||||
}
|
||||
man, err := delivery.DecodeManifest(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(man.JSON), nil
|
||||
}
|
||||
|
||||
// composerOf is the composer kept under key, or, when none is, the one built now from the fetched commit (built true).
|
||||
func (m *Module) composerOf(ctx context.Context, fetch buildstep.StepFunc, job delivery.ComposeJob, key string) (string, bool, error) {
|
||||
exe := m.composerPath(key)
|
||||
mu, _ := composerLocks.LoadOrStore(exe, &sync.Mutex{})
|
||||
mu.(*sync.Mutex).Lock()
|
||||
defer mu.(*sync.Mutex).Unlock()
|
||||
if st, err := os.Stat(exe); err == nil && st.Mode().IsRegular() {
|
||||
return exe, false, nil
|
||||
}
|
||||
if fetch == nil {
|
||||
return "", false, buildstep.Refuse("this build module fetches nothing")
|
||||
}
|
||||
out, err := fetch(ctx, job.Fetch)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
raw, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
var f struct {
|
||||
Workspace string `json:"workspace"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &f); err != nil || f.Workspace == "" {
|
||||
return "", false, fmt.Errorf("the fetch answered no workspace: %s", raw)
|
||||
}
|
||||
defer m.releaseSaid(f.Workspace)
|
||||
done := m.spaces().Hold(f.Workspace)
|
||||
defer done()
|
||||
dir := filepath.Join(m.Work, f.Workspace, "tree", job.Dir)
|
||||
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err != nil {
|
||||
return "", false, fmt.Errorf("%s at %s holds no go.mod in %s, so no composer can be built there", job.Fetch["repository"], job.Fetch["commit"], job.Dir)
|
||||
}
|
||||
into := filepath.Join(m.Work, f.Workspace, "composer", "mesh-compose")
|
||||
if err := os.MkdirAll(filepath.Dir(into), 0o700); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
run, err := m.stepRunner(f.Workspace, false)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
env := append(m.goEnvFor(dir), "GOPROXY=off", "GOTOOLCHAIN=local", "CGO_ENABLED=0")
|
||||
r := run(ctx, env, "go", "-C", dir, "build", "-trimpath", "-o", into, job.Package)
|
||||
if r.Status != 0 || r.Err != "" {
|
||||
// The words Go says when the module holds no such package (a mesh-sdk from before the composer) are kept whole,
|
||||
// so the asker tells that apart from a fault.
|
||||
return "", false, fmt.Errorf("the composer %s does not build at %s: %s", job.Package, job.Fetch["commit"], compileError(r.Err+"\n"+r.Stderr+"\n"+r.Stdout))
|
||||
}
|
||||
if err := copyExecutable(into, exe); err != nil {
|
||||
return "", false, fmt.Errorf("the composer could not be kept: %w", err)
|
||||
}
|
||||
return exe, true, nil
|
||||
}
|
||||
|
||||
// copyExecutable copies a built program into place through a file beside it, so a half-written program is never kept.
|
||||
func copyExecutable(from, to string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(to), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
src, err := os.Open(from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer src.Close()
|
||||
tmp := to + ".new"
|
||||
dst, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o700)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
dst.Close()
|
||||
return err
|
||||
}
|
||||
if err := dst.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, to)
|
||||
}
|
||||
|
||||
// releaseSaid removes a workspace once its steps ended, saying a failure: the sweep removes it later.
|
||||
func (m *Module) releaseSaid(workspace string) {
|
||||
if err := m.spaces().Release(workspace); err != nil && m.Say != nil {
|
||||
m.Say("the compose ask's workspace %s: %v", workspace, err)
|
||||
}
|
||||
}
|
||||
|
||||
// runComposer runs the composer over req in a workspace of its own, shut off, and answers its verdict: the last line of
|
||||
// its standard output, a composer.Verdict of the version this module reads.
|
||||
func (m *Module) runComposer(ctx context.Context, exe string, req composer.Request) (json.RawMessage, error) {
|
||||
id := buildstep.NewID()
|
||||
done := m.spaces().Hold(id)
|
||||
defer m.releaseSaid(id)
|
||||
defer done()
|
||||
ws := filepath.Join(m.Work, id)
|
||||
if err := os.MkdirAll(ws, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file := filepath.Join(ws, "request.json")
|
||||
if err := os.WriteFile(file, raw, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
run := m.Run
|
||||
if !m.Inside {
|
||||
iso := m.isolation(id, false)
|
||||
iso.ReadOnly = append(iso.ReadOnly, exe)
|
||||
run = buildstep.Isolated(m.Run, iso)
|
||||
}
|
||||
r := run(ctx, nil, exe, "-request", file)
|
||||
line := strings.TrimSpace(lastLine(r.Stdout))
|
||||
var v composer.Verdict
|
||||
if r.Status != 0 || r.Err != "" || json.Unmarshal([]byte(line), &v) != nil || v.V != composer.Version {
|
||||
return nil, fmt.Errorf("the composer answered no verdict (status %d %s): %s", r.Status, r.Err, firstLine(r.Stderr+"\n"+r.Stdout))
|
||||
}
|
||||
return json.RawMessage(line), nil
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/composer"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
"git.novox.be/novox/mesh-sdk/go/testbus"
|
||||
)
|
||||
|
||||
// sdkPin is the mesh-sdk version this module's go.mod requires: the composer the test builds is that version's.
|
||||
func sdkPin(t *testing.T) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("../../go.mod")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, l := range strings.Split(string(raw), "\n") {
|
||||
if f := strings.Fields(l); len(f) >= 2 && f[0] == "git.novox.be/novox/mesh-sdk/go" {
|
||||
return f[1]
|
||||
}
|
||||
}
|
||||
t.Fatal("go.mod requires no mesh-sdk")
|
||||
return ""
|
||||
}
|
||||
|
||||
// goEnvOf is one value of this machine's go env.
|
||||
func goEnvOf(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("go", "env", name).Output()
|
||||
if err != nil {
|
||||
t.Fatalf("go env %s: %v", name, err)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// 1622/AC1, AC3 against the real bus: a compose ask put on the Go build seat's check stream is pulled by the module
|
||||
// started whole on that bus, which fetches the commit (here a tree of a module requiring mesh-sdk at this module's pin,
|
||||
// as a reader repository is), builds mesh-sdk's composer there with nothing fetched, fills the request's manifests from
|
||||
// the indexer's state and runs the composer; the step key <node>.<ask id> passes with the composer's verdict. A second
|
||||
// ask of the same commit runs the kept composer: fetched and built once.
|
||||
func TestAComposeAskIsBuiltOnceAndAnsweredOnTheBus(t *testing.T) {
|
||||
nc, err := nats.Connect(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer nc.Close()
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, stop := context.WithCancel(context.Background())
|
||||
defer stop()
|
||||
if err := buildstep.DeclareAskWorkers(ctx, js, delivery.AskGo, 2*time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = js.DeleteStream(context.Background(), "SEAT_NODE_BUILD_GO") })
|
||||
|
||||
states := newStates()
|
||||
m := moduleWith(states, "go1.26.0")
|
||||
m.Work, m.Cache, m.Inside = t.TempDir(), t.TempDir(), true
|
||||
m.Run = StepRunner(t.TempDir())
|
||||
m.GoModCache = goEnvOf(t, "GOMODCACHE") // the module cache this module's own build filled, read and not fetched
|
||||
run, gocache := m.Run, goEnvOf(t, "GOCACHE") // this machine's build cache, so the composer's packages compile once
|
||||
m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
||||
return run(ctx, append(env, "GOCACHE="+gocache), name, args...)
|
||||
}
|
||||
read, digests := indexerHolds(t, `{"module":"demo","version":"1"}`)
|
||||
m.ReadManifest = read
|
||||
|
||||
gosum, err := os.ReadFile("../../go.sum")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fetches := 0
|
||||
steps := fakeSteps(new([]string))
|
||||
for i := range steps {
|
||||
if steps[i].Name == "build_go_fetch" {
|
||||
steps[i].Run = func(ctx context.Context, args map[string]any) (any, error) {
|
||||
fetches++
|
||||
ws := buildstep.NewID()
|
||||
tree := filepath.Join(m.Work, ws, "tree")
|
||||
if err := os.MkdirAll(tree, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mod := "module reader\n\ngo 1.26.0\n\nrequire git.novox.be/novox/mesh-sdk/go " + sdkPin(t) + "\n"
|
||||
if err := os.WriteFile(filepath.Join(tree, "go.mod"), []byte(mod), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"workspace": ws}, os.WriteFile(filepath.Join(tree, "go.sum"), gosum, 0o600)
|
||||
}
|
||||
}
|
||||
}
|
||||
p := m.Puller(steps, &buildstep.JetStreamAsks{JS: js, Language: delivery.AskGo})
|
||||
p.PullWait = 200 * time.Millisecond
|
||||
go p.Pull(ctx)
|
||||
|
||||
ask := func() delivery.Step {
|
||||
a := composeAskOf(t, digests[0])
|
||||
a.Compose.Fetch["repository"], a.Compose.Dir = "novox/mesh-controller", delivery.RootPath
|
||||
raw, err := delivery.EncodeStepAsk(a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subject, _ := delivery.AskSubject(delivery.AskGo, delivery.AskCheck)
|
||||
if _, err := js.Publish(ctx, subject, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, _ := delivery.AskStepKey("ace", a.ID)
|
||||
for deadline := time.Now().Add(90 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
|
||||
states.mu.Lock()
|
||||
v := states.steps[key]
|
||||
states.mu.Unlock()
|
||||
if len(v) > 0 && v[len(v)-1].State != delivery.StepRunning {
|
||||
return v[len(v)-1]
|
||||
}
|
||||
}
|
||||
t.Fatalf("the compose ask %s never ended", a.ID)
|
||||
return delivery.Step{}
|
||||
}
|
||||
for n, wantBuilt := range []bool{true, false} {
|
||||
step := ask()
|
||||
if step.State != delivery.StepPassed {
|
||||
t.Fatalf("ask %d: %s: %s", n+1, step.State, step.Reason)
|
||||
}
|
||||
var ans delivery.ComposeAnswer
|
||||
var v composer.Verdict
|
||||
if err := json.Unmarshal(step.Result, &ans); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(ans.Verdict, &v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ans.Built != wantBuilt || !strings.HasPrefix(ans.Key, "composer-") || len(v.Cases) != 1 || !v.Cases[0].Manifests["demo"].Accepted ||
|
||||
!strings.Contains(v.SDK, sdkPin(t)) {
|
||||
t.Fatalf("ask %d: answer built %v key %s, verdict %+v; want built %v and demo accepted by mesh-sdk %s", n+1, ans.Built, ans.Key, v, wantBuilt, sdkPin(t))
|
||||
}
|
||||
}
|
||||
if fetches != 1 {
|
||||
t.Errorf("two asks of one commit fetched %d times, want once", fetches)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/composer"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
const composeCommit = "4444444444444444444444444444444444444444"
|
||||
|
||||
// indexerHolds is the delivery indexer's manifests state holding each body under its digest, as the indexer writes it.
|
||||
func indexerHolds(t *testing.T, bodies ...string) (func(string) (json.RawMessage, error), []string) {
|
||||
t.Helper()
|
||||
kept, digests := map[string]json.RawMessage{}, []string{}
|
||||
for _, b := range bodies {
|
||||
d := delivery.ManifestDigest([]byte(b))
|
||||
key, _ := delivery.ManifestsKey(d)
|
||||
raw, err := delivery.EncodeManifest(delivery.Manifest{Module: "demo", JSON: b})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kept[key], digests = raw, append(digests, d)
|
||||
}
|
||||
return func(key string) (json.RawMessage, error) { return kept[key], nil }, digests
|
||||
}
|
||||
|
||||
func composeAskOf(t *testing.T, digest string) delivery.StepAsk {
|
||||
t.Helper()
|
||||
req, _ := json.Marshal(composer.Request{V: composer.Version, Cases: []composer.Case{{Name: "change",
|
||||
Manifests: map[string]composer.Manifest{"demo": {Digest: digest}}}}})
|
||||
return delivery.StepAsk{ID: buildstep.NewID(), Kind: delivery.AskCheck, Language: delivery.AskGo, Asker: "mesh-delivery-assigner",
|
||||
At: time.Now(), Compose: &delivery.ComposeJob{Fetch: map[string]any{"repository": "novox/mesh-sdk", "commit": composeCommit},
|
||||
Dir: "go", Package: composer.Package, Request: req}}
|
||||
}
|
||||
|
||||
// composeModule is a module with work and cache directories of its own, running its commands as they are (Inside: a
|
||||
// unit test has no bubblewrap to shut them off in).
|
||||
func composeModule(t *testing.T) *Module {
|
||||
t.Helper()
|
||||
m := moduleWith(newStates(), "go1.26.0")
|
||||
m.Work, m.Cache, m.Inside, m.Run = t.TempDir(), t.TempDir(), true, StepRunner(t.TempDir())
|
||||
return m
|
||||
}
|
||||
|
||||
// 1622/AC3: a composer kept under the ask's key is run again, nothing fetched or built: the answer says it was not
|
||||
// built, names the key, and carries the composer's verdict whole; the request it read had its manifests filled from the
|
||||
// indexer's state.
|
||||
func TestAKeptComposerIsRunWithTheManifestsFilled(t *testing.T) {
|
||||
m := composeModule(t)
|
||||
read, digests := indexerHolds(t, `{"module":"demo","version":"1"}`)
|
||||
m.ReadManifest = read
|
||||
a := composeAskOf(t, digests[0])
|
||||
key := delivery.ComposerKey("novox/mesh-sdk", composeCommit, "go", composer.Package, delivery.ToolchainUsed{Version: "go1.26.0", Recipe: m.Recipe.Version()})
|
||||
exe := m.composerPath(key)
|
||||
if err := os.MkdirAll(filepath.Dir(exe), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A composer that answers which manifests its request held, filled or not, as a verdict.
|
||||
script := "#!/bin/sh\n" +
|
||||
`if grep -q '"digest"' "$2"; then echo '{"v":1,"sdk":"stand-in","cases":[{"name":"unfilled","manifests":{}}]}'; ` +
|
||||
`else echo '{"v":1,"sdk":"stand-in","cases":[{"name":"change","manifests":{"demo":{"accepted":true}}}]}'; fi` + "\n"
|
||||
if err := os.WriteFile(exe, []byte(script), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fetched := 0
|
||||
fetch := func(context.Context, map[string]any) (any, error) { fetched++; return nil, nil }
|
||||
out, err := m.compose(context.Background(), fetch, a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ans := out.(delivery.ComposeAnswer)
|
||||
var v composer.Verdict
|
||||
if err := json.Unmarshal(ans.Verdict, &v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ans.Built || ans.Key != key || fetched != 0 || len(v.Cases) != 1 || v.Cases[0].Name != "change" || !v.Cases[0].Manifests["demo"].Accepted {
|
||||
t.Fatalf("answer = %+v (fetched %d), verdict %+v", ans, fetched, v)
|
||||
}
|
||||
if entries, _ := os.ReadDir(m.Work); len(entries) != 0 {
|
||||
t.Errorf("the run's workspace was left: %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
// 1622: a manifest the indexer does not keep fails the ask, naming its digest; a fetched commit with no go.mod where
|
||||
// the ask builds fails it naming the directory, and its workspace is released.
|
||||
func TestAComposeAskFailsLoudly(t *testing.T) {
|
||||
m := composeModule(t)
|
||||
m.ReadManifest, _ = indexerHolds(t)
|
||||
a := composeAskOf(t, "feedfeedfeedfeedfeedfeedfeedfeedfeedfeedfeedfeedfeedfeedfeedfeed")
|
||||
key := delivery.ComposerKey("novox/mesh-sdk", composeCommit, "go", composer.Package, delivery.ToolchainUsed{Version: "go1.26.0", Recipe: m.Recipe.Version()})
|
||||
os.MkdirAll(filepath.Dir(m.composerPath(key)), 0o700)
|
||||
os.WriteFile(m.composerPath(key), []byte("#!/bin/sh\nexit 0\n"), 0o700)
|
||||
if _, err := m.compose(context.Background(), nil, a); err == nil || !strings.Contains(err.Error(), "feedfeed") {
|
||||
t.Errorf("an unkept manifest: %v", err)
|
||||
}
|
||||
m2 := composeModule(t)
|
||||
fetch := func(context.Context, map[string]any) (any, error) {
|
||||
if err := os.MkdirAll(filepath.Join(m2.Work, "ws0000000000a", "tree", "go"), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"workspace": "ws0000000000a"}, nil
|
||||
}
|
||||
if _, err := m2.compose(context.Background(), fetch, composeAskOf(t, "x")); err == nil || !strings.Contains(err.Error(), "no go.mod in go") {
|
||||
t.Errorf("a commit with no go.mod: %v", err)
|
||||
}
|
||||
if entries, _ := os.ReadDir(m2.Work); len(entries) != 0 {
|
||||
t.Errorf("the fetched workspace was left: %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
// 1622: the module reads the delivery indexer's manifests, which a compose ask names by digest.
|
||||
func TestTheModuleReadsTheIndexersManifests(t *testing.T) {
|
||||
if m := readManifest(t); !slices.Contains(m.Reads, ManifestsState) {
|
||||
t.Errorf("reads = %v, lacks %s", m.Reads, ManifestsState)
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,51 @@ type FetchResult struct {
|
||||
// Beside are the sibling repositories placed as plain directories under the workspace's beside directory, which
|
||||
// build_go_test hands to the tests as MESH_CHECK_BESIDE (ADR 0484 clause 4).
|
||||
Beside []Sibling `json:"beside,omitempty"`
|
||||
// Took is how long each phase of the fetch ran, in seconds (issue 1921, the try speed-up's first measurement): what
|
||||
// the checker reports as one "clone" time, split.
|
||||
Took *FetchTook `json:"took,omitempty"`
|
||||
}
|
||||
|
||||
// FetchTook is the time of each phase of a fetch, in seconds; its phases add up to Total. Mirror is bringing the
|
||||
// repository's mirror to the commit, Fetch the fetch and checkout into the tree, Merge the merge preview, Dissociate
|
||||
// the repack that makes the tree independent of the mirror, Beside the placing of the siblings (their own mirror,
|
||||
// fetch and repack), Other the narrowing and the listing of the go.mod directories, and Modules the download and
|
||||
// verify of the Go modules.
|
||||
type FetchTook struct {
|
||||
Mirror float64 `json:"mirror"`
|
||||
Fetch float64 `json:"fetch"`
|
||||
Merge float64 `json:"preview"` // named for the merge preview, apart from the result's merge commit
|
||||
Dissociate float64 `json:"dissociate"`
|
||||
Beside float64 `json:"siblings"`
|
||||
Other float64 `json:"other"`
|
||||
Modules float64 `json:"modules"`
|
||||
Total float64 `json:"total"`
|
||||
}
|
||||
|
||||
// phaseClock stamps the phases of a fetch: each lap is the time since the one before, so the laps add up to the total.
|
||||
type phaseClock struct {
|
||||
now func() time.Time
|
||||
start, since time.Time
|
||||
}
|
||||
|
||||
func newPhaseClock(now func() time.Time) *phaseClock {
|
||||
t := now()
|
||||
return &phaseClock{now: now, start: t, since: t}
|
||||
}
|
||||
|
||||
// lap adds the time since the last lap to *into.
|
||||
func (c *phaseClock) lap(into *float64) {
|
||||
t := c.now()
|
||||
*into += t.Sub(c.since).Seconds()
|
||||
c.since = t
|
||||
}
|
||||
|
||||
func (c *phaseClock) total() float64 { return c.since.Sub(c.start).Seconds() }
|
||||
|
||||
// String is the one log line a fetch says of its phases.
|
||||
func (t FetchTook) String() string {
|
||||
return fmt.Sprintf("mirror %.1fs, fetch %.1fs, merge %.1fs, dissociate %.1fs, beside %.1fs, other %.1fs, modules %.1fs, total %.1fs",
|
||||
t.Mirror, t.Fetch, t.Merge, t.Dissociate, t.Beside, t.Other, t.Modules, t.Total)
|
||||
}
|
||||
|
||||
// fetchTool is the fetch step tool.
|
||||
@@ -109,43 +154,77 @@ func (m *Module) fetch(ctx context.Context, args map[string]any) (res any, err e
|
||||
}
|
||||
|
||||
func (m *Module) fetchInto(ctx context.Context, req buildstep.FetchRequest, id string, siblings []siblingArg) (any, error) {
|
||||
res, err := m.fetchPhases(ctx, req, id, siblings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.Say != nil && res.Took != nil {
|
||||
m.Say("the fetch of %s at %s took %s", req.Repository, short(req.Commit), res.Took)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func short(commit string) string {
|
||||
if len(commit) > 12 {
|
||||
return commit[:12]
|
||||
}
|
||||
return commit
|
||||
}
|
||||
|
||||
func (m *Module) fetchPhases(ctx context.Context, req buildstep.FetchRequest, id string, siblings []siblingArg) (FetchResult, error) {
|
||||
repository, commit, ref, base := req.Repository, req.Commit, req.Ref, req.Base
|
||||
var err error
|
||||
took := &FetchTook{}
|
||||
clock := newPhaseClock(m.now)
|
||||
defer func() { took.Total = clock.total() }()
|
||||
root := filepath.Join(m.Work, id)
|
||||
tree := filepath.Join(root, "tree")
|
||||
if err := os.MkdirAll(tree, 0o700); err != nil {
|
||||
return nil, err
|
||||
return FetchResult{}, err
|
||||
}
|
||||
remote := m.remote(repository)
|
||||
if err := buildstep.FetchCommit(ctx, m.Run, tree, remote, commit, ref, m.fetchOptions(ctx, repository, commit, ref)...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := FetchResult{Workspace: id, Commit: commit}
|
||||
opts := m.fetchOptions(ctx, repository, commit, ref)
|
||||
mergeOpts := m.FetchOptions
|
||||
if base != "" {
|
||||
if err := m.merge(ctx, &res, tree, remote, base, commit); err != nil {
|
||||
return nil, err
|
||||
// The mirror is brought to the trunk commit too, so the merge preview deepens the tree from it and asks the forge
|
||||
// for no history the mirror holds (issue 1921, try speed S6).
|
||||
mergeOpts = m.fetchOptions(ctx, repository, base, "")
|
||||
}
|
||||
clock.lap(&took.Mirror)
|
||||
if err := buildstep.FetchCommit(ctx, m.Run, tree, remote, commit, ref, opts...); err != nil {
|
||||
return FetchResult{}, err
|
||||
}
|
||||
clock.lap(&took.Fetch)
|
||||
res := FetchResult{Workspace: id, Commit: commit, Took: took}
|
||||
if base != "" {
|
||||
if err := m.merge(ctx, &res, tree, remote, base, commit, mergeOpts); err != nil {
|
||||
return FetchResult{}, err
|
||||
}
|
||||
}
|
||||
clock.lap(&took.Merge)
|
||||
if err := m.dissociate(ctx, tree); err != nil {
|
||||
return nil, err
|
||||
return FetchResult{}, err
|
||||
}
|
||||
clock.lap(&took.Dissociate)
|
||||
if res.Beside, err = m.placeSiblings(ctx, root, siblings); err != nil {
|
||||
return nil, err
|
||||
return FetchResult{}, err
|
||||
}
|
||||
clock.lap(&took.Beside)
|
||||
if (base == "" || res.Merge != "") && len(req.Paths) > 0 {
|
||||
if err := buildstep.Narrow(tree, req); err != nil {
|
||||
return nil, err
|
||||
return FetchResult{}, err
|
||||
}
|
||||
// Said beside the tree, so the steps that list the tree's go.mod files leave out those narrowed away.
|
||||
if err := os.WriteFile(filepath.Join(root, narrowedMark), nil, 0o600); err != nil {
|
||||
return nil, err
|
||||
return FetchResult{}, err
|
||||
}
|
||||
}
|
||||
dirs, err := m.goModDirs(ctx, tree)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return FetchResult{}, err
|
||||
}
|
||||
res.Modules = dirs
|
||||
clock.lap(&took.Other)
|
||||
if base != "" && res.Merge == "" {
|
||||
return res, nil // a conflict: the tree is at the trunk, nothing of the head is built or tested
|
||||
}
|
||||
@@ -168,9 +247,10 @@ func (m *Module) fetchInto(ctx context.Context, req buildstep.FetchRequest, id s
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
clock.lap(&took.Modules)
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return FetchResult{}, err
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
@@ -288,10 +368,15 @@ func inTestdata(path string) bool {
|
||||
// git, through the account's rewrite of the forge's address to ssh), and nothing that asks a question. For a directory
|
||||
// that vendors, see goEnvFor.
|
||||
func (m *Module) goEnv() []string {
|
||||
modcache := filepath.Join(m.Cache, "gomod")
|
||||
if m.GoModCache != "" {
|
||||
modcache = m.GoModCache
|
||||
}
|
||||
return []string{
|
||||
"CGO_ENABLED=0", // as a production build compiles (build.go), so a check tests what ships (issue 1921, S11)
|
||||
"GOTOOLCHAIN=local",
|
||||
"GOFLAGS=-mod=mod",
|
||||
"GOMODCACHE=" + filepath.Join(m.Cache, "gomod"),
|
||||
"GOMODCACHE=" + modcache,
|
||||
"GOCACHE=" + filepath.Join(m.Cache, "gobuild"),
|
||||
"GOPRIVATE=git.novox.be",
|
||||
"GIT_TERMINAL_PROMPT=0",
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -302,3 +303,61 @@ func TestWorkAndCacheAreTheDirectoriesTheManifestPlacesOutsideTheHome(t *testing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1921/S1: the fetch says how long each phase ran (mirror, fetch, merge, dissociate, beside, other, modules), and the
|
||||
// phases add up to its total, so a try's "clone" time is split into its parts.
|
||||
func TestTheFetchSaysTheTimeOfEachPhaseAndTheyAddUpToTheTotal(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
now := time.Date(2026, 10, 25, 9, 0, 0, 0, time.UTC)
|
||||
advance := func(s int) {
|
||||
mu.Lock()
|
||||
now = now.Add(time.Duration(s) * time.Second)
|
||||
mu.Unlock()
|
||||
}
|
||||
f := &fakeStep{gomods: "go.mod\n", files: map[string]string{"go.mod": "module x\n", "go.sum": ""}, rule: func(line string) (forgekey.Ran, bool) {
|
||||
switch {
|
||||
case strings.Contains(line, "/mirror/") && strings.Contains(line, " fetch "):
|
||||
advance(1)
|
||||
case strings.Contains(line, " fetch --no-tags --depth 1 "):
|
||||
advance(2)
|
||||
case strings.Contains(line, " merge --no-ff "):
|
||||
advance(3)
|
||||
case strings.Contains(line, " repack "):
|
||||
advance(4)
|
||||
case strings.HasSuffix(line, " mod download"):
|
||||
advance(5)
|
||||
}
|
||||
return forgekey.Ran{}, false
|
||||
}}
|
||||
m, states := fetchModule(t, f)
|
||||
said := []string{}
|
||||
m.Say = func(format string, a ...any) { said = append(said, fmt.Sprintf(format, a...)) }
|
||||
m.Now = func() time.Time { mu.Lock(); defer mu.Unlock(); return now }
|
||||
args := map[string]any{"repository": "novox/mesh-sdk", "commit": commitSHA, "base": strings.Repeat("b", 40)}
|
||||
if _, err := fetchTool(t, m)(args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := states.waitEnd(t)
|
||||
if st.State != delivery.StepPassed {
|
||||
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
|
||||
}
|
||||
var res FetchResult
|
||||
if err := json.Unmarshal(st.Result, &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Took == nil {
|
||||
t.Fatalf("the result says no phase times: %s", st.Result)
|
||||
}
|
||||
got := *res.Took
|
||||
// the mirror is brought to the head and to the base (1 s each), and the preview deepens from it (1 s) before merging
|
||||
want := FetchTook{Mirror: 2, Fetch: 2, Merge: 4, Dissociate: 4, Modules: 5, Total: 17}
|
||||
if got != want {
|
||||
t.Errorf("phases = %+v, want %+v", got, want)
|
||||
}
|
||||
if sum := got.Mirror + got.Fetch + got.Merge + got.Dissociate + got.Beside + got.Other + got.Modules; sum != got.Total {
|
||||
t.Errorf("the phases add up to %v, not the total %v", sum, got.Total)
|
||||
}
|
||||
if !strings.Contains(strings.Join(said, "\n"), "dissociate 4.0s") {
|
||||
t.Errorf("no log line names the phases: %q", said)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
@@ -62,34 +64,99 @@ func (m *Module) integrationHere(ctx context.Context, workspace, tree string, pa
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
postgres, stopStore, err := m.startStore(ctx, run)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the integration level's store: %w", err)
|
||||
}
|
||||
defer stopStore()
|
||||
nats, stopBus, err := m.startBus(ctx, filepath.Join(run, "nats"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the integration level's bus: %w", err)
|
||||
}
|
||||
defer stopBus()
|
||||
env := append([]string{"MESH_TEST_POSTGRES=" + postgres, "MESH_TEST_NATS=" + nats}, m.besideEnv(workspace)...)
|
||||
// Compiling counts as setup: the tests are built once, here, with the addresses a TestMain may read, and the run
|
||||
// below finds them built. Whether they compile is for the run to say; its failure is not read here.
|
||||
// Only the directories the selected packages lie in compile (ADR 0536 §3); no selection is every directory.
|
||||
byDir, err := packagesByDir(tree, dirs, packages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The tests the tags add, not the unit tests again (issue 1921, try speed S7): only the packages holding one compile
|
||||
// and run, with a -run pattern naming them. A try that names its own pattern runs it as asked.
|
||||
var runByDir map[string]string
|
||||
if pattern == "" {
|
||||
tagged, err := taggedTests(tree, dirs, packages, integrationTags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(tagged) == 0 {
|
||||
res := &TestResult{Level: LevelIntegration, Packages: []PackageResult{}}
|
||||
res.judge(m.integrationBounds())
|
||||
return res, nil // no test the tags add: nothing to start or run
|
||||
}
|
||||
byDir, runByDir, packages = map[string][]string{}, map[string]string{}, nil
|
||||
for d, tr := range tagged {
|
||||
byDir[d], runByDir[d] = tr.packages, tr.pattern
|
||||
packages = append(packages, tr.packages...)
|
||||
}
|
||||
sort.Strings(packages)
|
||||
}
|
||||
// The store, the bus and the compile start together (issue 1921, try speed S5); the setup is the longest of them.
|
||||
// The compile runs no test binary (-exec true), so it needs neither server: whether the tests compile is for the run
|
||||
// to say, and its failure is not read here; the run below finds them built.
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
postgres, nats string
|
||||
stopStore, stopBus func()
|
||||
storeErr, busErr error
|
||||
crash any
|
||||
crashMu sync.Mutex
|
||||
)
|
||||
guard := func() {
|
||||
if p := recover(); p != nil {
|
||||
crashMu.Lock()
|
||||
crash = p
|
||||
crashMu.Unlock()
|
||||
}
|
||||
}
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); defer guard(); postgres, stopStore, storeErr = m.startStore(ctx, run) }()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer guard()
|
||||
nats, stopBus, busErr = m.startBus(ctx, filepath.Join(run, "nats"))
|
||||
}()
|
||||
slots := m.slots(workspace) // shared with the ask's unit level (issue 1921, try speed S10)
|
||||
var slotErr error
|
||||
for _, d := range dirs {
|
||||
if len(byDir[d]) == 0 {
|
||||
continue
|
||||
}
|
||||
m.Run(ctx, append(m.goEnvFor(filepath.Join(tree, d)), env...), "go", append([]string{"-C", filepath.Join(tree, d), "test", "-tags", strings.Join(integrationTags, ","), "-run", "^$", "-count=1"}, byDir[d]...)...)
|
||||
release, err := slots.acquire(ctx)
|
||||
if err != nil {
|
||||
slotErr = err
|
||||
break
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer release()
|
||||
defer guard()
|
||||
m.Run(ctx, m.goEnvFor(filepath.Join(tree, d)), "go", append([]string{"-C", filepath.Join(tree, d), "test", "-tags", strings.Join(integrationTags, ","), "-exec", "true", "-count=1"}, byDir[d]...)...)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if stopStore != nil {
|
||||
defer stopStore()
|
||||
}
|
||||
if stopBus != nil {
|
||||
defer stopBus()
|
||||
}
|
||||
if crash != nil {
|
||||
panic(crash)
|
||||
}
|
||||
if slotErr != nil {
|
||||
return nil, slotErr
|
||||
}
|
||||
if storeErr != nil {
|
||||
return nil, fmt.Errorf("the integration level's store: %w", storeErr)
|
||||
}
|
||||
if busErr != nil {
|
||||
return nil, fmt.Errorf("the integration level's bus: %w", busErr)
|
||||
}
|
||||
env := append([]string{"MESH_TEST_POSTGRES=" + postgres, "MESH_TEST_NATS=" + nats}, m.besideEnv(workspace)...)
|
||||
setup := m.clock().Sub(began)
|
||||
|
||||
return m.runLevel(ctx, tree, levelOpts{
|
||||
workspace: workspace, run: pattern,
|
||||
workspace: workspace, run: pattern, runByDir: runByDir,
|
||||
level: LevelIntegration, tags: integrationTags, packages: packages, bounds: m.integrationBounds(),
|
||||
env: env,
|
||||
setup: setup, wall: true,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -35,6 +36,8 @@ type integFake struct {
|
||||
// command runs, with its line.
|
||||
gomods string
|
||||
onTest func(line string)
|
||||
// onCompile is called, unlocked, as each compile of the setup runs (issue 1921, try speed S5).
|
||||
onCompile func(line string)
|
||||
}
|
||||
|
||||
func newIntegFake(t *testing.T, captured string) *integFake {
|
||||
@@ -71,7 +74,10 @@ func (f *integFake) run(ctx context.Context, env []string, name string, args ...
|
||||
return forgekey.Ran{Stdout: f.gomods}
|
||||
}
|
||||
return forgekey.Ran{Stdout: "go.mod\n"}
|
||||
case name == "go" && strings.Contains(line, "-run ^$"):
|
||||
case name == "go" && strings.Contains(line, "-exec true"):
|
||||
if f.onCompile != nil {
|
||||
f.onCompile(line)
|
||||
}
|
||||
f.tick("compile")
|
||||
case name == "go":
|
||||
if f.onTest != nil {
|
||||
@@ -123,9 +129,32 @@ func integModule(t *testing.T, f *integFake) (*Module, *keptStates) {
|
||||
if err := os.MkdirAll(filepath.Join(m.Work, "ws1", "tree"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A test the integration tag adds, so the level has a package to run (issue 1921, try speed S7).
|
||||
writeTaggedTest(t, filepath.Join(m.Work, "ws1", "tree"), "fixture/slow")
|
||||
return m, states
|
||||
}
|
||||
|
||||
// taggedTestFile is the file writeTaggedTest writes: one test the integration tag adds.
|
||||
const taggedTestFile = "level_integration_test.go"
|
||||
|
||||
// writeTaggedTest gives the go.mod directory dir the module path mp and one test the integration tag adds, in its root
|
||||
// package and in its package x.
|
||||
func writeTaggedTest(t *testing.T, dir, mp string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module "+mp+"\n\ngo 1.25.0\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := "//go:build integration\n\npackage x\n\nimport \"testing\"\n\nfunc TestTakesAWhile(t *testing.T) {}\n"
|
||||
for _, d := range []string{dir, filepath.Join(dir, "x")} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(d, taggedTestFile), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runLevelTool(t *testing.T, m *Module, states *keptStates, level string) (TestResult, delivery.Step) {
|
||||
t.Helper()
|
||||
for _, tool := range m.Tools(m.StepTools()) {
|
||||
@@ -314,11 +343,11 @@ func TestAStoreThatDoesNotStartEndsTheStepFailedAndLeavesNothing(t *testing.T) {
|
||||
if !strings.Contains(f.lines(), " stop") {
|
||||
t.Errorf("the half-started store was not stopped:\n%s", f.lines())
|
||||
}
|
||||
if f.busDir != "" {
|
||||
t.Errorf("the bus was started after the store failed")
|
||||
if f.busDir != "" && !f.busDown {
|
||||
t.Errorf("the bus, started beside the store, was not stopped after the store failed")
|
||||
}
|
||||
if entries, _ := os.ReadDir(filepath.Join(m.Work, "ws1")); len(entries) != 1 || entries[0].Name() != "tree" {
|
||||
t.Errorf("the workspace holds %v, want only its tree", entries)
|
||||
if left := workspaceLeft(filepath.Join(m.Work, "ws1")); len(left) != 1 || left[0] != "tree" {
|
||||
t.Errorf("the workspace holds %v, want only its tree", left)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,3 +406,138 @@ func TestTheSocketPathStaysUnderTheLimitForALongRunDirectory(t *testing.T) {
|
||||
t.Errorf("the short socket directory %s outlived the store (%v)", sock, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1921/S5: the setup compiles the go.mod directories side by side, up to the bound, beside the store's and the bus's
|
||||
// start, and compiles without running a test binary, so it needs neither; the setup stays one number.
|
||||
func TestTheSetupCompilesTheDirectoriesAtOnceBesideTheStoreAndTheBus(t *testing.T) {
|
||||
f := newIntegFake(t, "slow")
|
||||
f.gomods = "a/go.mod\nb/go.mod\nc/go.mod\nd/go.mod\ne/go.mod\n"
|
||||
var mu sync.Mutex
|
||||
running, most := 0, 0
|
||||
storeStarting, storeUp, overlap := false, false, false
|
||||
f.onCompile = func(line string) {
|
||||
mu.Lock()
|
||||
running++
|
||||
most = max(most, running)
|
||||
mu.Unlock()
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
mu.Lock()
|
||||
running--
|
||||
mu.Unlock()
|
||||
}
|
||||
m, states := integModule(t, f)
|
||||
m.Parallel = 3
|
||||
tree := filepath.Join(m.Work, "ws1", "tree")
|
||||
for _, d := range []string{"a", "b", "c", "d", "e"} {
|
||||
os.MkdirAll(filepath.Join(tree, d), 0o755)
|
||||
writeTaggedTest(t, filepath.Join(tree, d), "x/"+d)
|
||||
}
|
||||
inner := m.Run
|
||||
m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
||||
line := strings.Join(args, " ")
|
||||
if name == "pg_ctl" && strings.HasSuffix(line, " start") {
|
||||
mu.Lock()
|
||||
storeStarting = true
|
||||
mu.Unlock()
|
||||
time.Sleep(100 * time.Millisecond) // the store takes a while to start
|
||||
defer func() { mu.Lock(); storeUp = true; mu.Unlock() }()
|
||||
}
|
||||
if name == "go" && strings.Contains(line, "-exec true") {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
mu.Lock()
|
||||
overlap = overlap || (storeStarting && !storeUp)
|
||||
mu.Unlock()
|
||||
for _, e := range env {
|
||||
if strings.HasPrefix(e, "MESH_TEST_") {
|
||||
t.Errorf("the compile was given %s: it runs no test binary", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
return inner(ctx, env, name, args...)
|
||||
}
|
||||
res, st := runLevelTool(t, m, states, "integration")
|
||||
if st.State != delivery.StepPassed {
|
||||
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
|
||||
}
|
||||
if most != 3 {
|
||||
t.Errorf("%d compiles ran at once, want the bound of 3", most)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !overlap {
|
||||
t.Errorf("no compile ran while the store was starting:\n%s", f.lines())
|
||||
}
|
||||
if res.Setup < 0 {
|
||||
t.Errorf("setup = %v", res.Setup)
|
||||
}
|
||||
}
|
||||
|
||||
// 1921/S7: the integration level runs the tests its tags add, not the unit tests again: only the packages holding a
|
||||
// tagged test compile and run, with a -run pattern naming those tests; a tree with no tagged test starts no store.
|
||||
func TestTheIntegrationLevelRunsOnlyTheTestsItsTagsAdd(t *testing.T) {
|
||||
f := newIntegFake(t, "slow")
|
||||
m, states := integModule(t, f)
|
||||
tree := filepath.Join(m.Work, "ws1", "tree")
|
||||
os.RemoveAll(filepath.Join(tree, "x"))
|
||||
os.Remove(filepath.Join(tree, taggedTestFile))
|
||||
for name, body := range map[string]string{
|
||||
"go.mod": "module fixture/slow\n",
|
||||
"unit/u_test.go": "package unit\n\nimport \"testing\"\n\nfunc TestUnit(t *testing.T) {}\n",
|
||||
"store/s_test.go": "package store\n\nimport \"testing\"\n\nfunc TestPlain(t *testing.T) {}\n",
|
||||
"store/s_integration_test.go": "//go:build integration\n\npackage store\n\nimport \"testing\"\n\nfunc TestMain(m *testing.M) {}\nfunc TestReal(t *testing.T) {}\nfunc Testable() {}\nfunc helper() {}\n",
|
||||
"store/fake_test.go": "//go:build !integration\n\npackage store\n\nimport \"testing\"\n\nfunc TestFake(t *testing.T) {}\n",
|
||||
"iso/iso_test.go": "//go:build integration && isolation && linux\n\npackage iso\n\nimport \"testing\"\n\nfunc TestShutOff(t *testing.T) {}\n",
|
||||
"plat/p_linux_test.go": "//go:build linux\n\npackage plat\n\nimport \"testing\"\n\nfunc TestLinux(t *testing.T) {}\n",
|
||||
} {
|
||||
os.MkdirAll(filepath.Dir(filepath.Join(tree, name)), 0o755)
|
||||
if err := os.WriteFile(filepath.Join(tree, name), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, st := runLevelTool(t, m, states, "integration"); st.State != delivery.StepPassed {
|
||||
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
|
||||
}
|
||||
var compiled, ran []string
|
||||
for _, c := range f.calls {
|
||||
switch {
|
||||
case strings.HasPrefix(c.line, "go ") && strings.Contains(c.line, "-exec true"):
|
||||
compiled = append(compiled, c.line)
|
||||
case strings.HasPrefix(c.line, "go ") && strings.Contains(c.line, " -json"):
|
||||
ran = append(ran, c.line)
|
||||
}
|
||||
}
|
||||
if len(ran) != 1 || !strings.Contains(ran[0], "-run ^(TestReal|TestShutOff)$") ||
|
||||
!strings.HasSuffix(ran[0], " fixture/slow/iso fixture/slow/store") {
|
||||
t.Errorf("the run = %q, want the tagged tests of iso and store alone", ran)
|
||||
}
|
||||
if len(compiled) != 1 || !strings.HasSuffix(compiled[0], " fixture/slow/iso fixture/slow/store") {
|
||||
t.Errorf("the compile = %q, want iso and store alone", compiled)
|
||||
}
|
||||
|
||||
g := newIntegFake(t, "slow")
|
||||
m, states = integModule(t, g)
|
||||
tree = filepath.Join(m.Work, "ws1", "tree")
|
||||
os.RemoveAll(filepath.Join(tree, "x"))
|
||||
os.Remove(filepath.Join(tree, taggedTestFile))
|
||||
os.WriteFile(filepath.Join(tree, "u_test.go"), []byte("package x\n\nimport \"testing\"\n\nfunc TestUnit(t *testing.T) {}\n"), 0o644)
|
||||
res, st := runLevelTool(t, m, states, "integration")
|
||||
if st.State != delivery.StepPassed || !res.Passed || !res.NoTests {
|
||||
t.Fatalf("a tree with no tagged test: %s %+v", st.State, res)
|
||||
}
|
||||
if strings.Contains(g.lines(), "pg_ctl") || strings.Contains(g.lines(), "go -C") {
|
||||
t.Errorf("a tree with no tagged test started the store or ran go:\n%s", g.lines())
|
||||
}
|
||||
}
|
||||
|
||||
// workspaceLeft is what a workspace holds besides the directories its ask keeps there (askDirs: its cache layer and its
|
||||
// bound on processes, gone with the workspace; issue 1921, S2 and S10).
|
||||
func workspaceLeft(dir string) []string {
|
||||
var left []string
|
||||
entries, _ := os.ReadDir(dir)
|
||||
for _, e := range entries {
|
||||
if !slices.Contains(askDirs, e.Name()) {
|
||||
left = append(left, e.Name())
|
||||
}
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
@@ -29,21 +29,30 @@ func (m *Module) caches() []string {
|
||||
}
|
||||
|
||||
// isolation is how a run step of the workspace is shut off: the workspace (its tree, its siblings, its run directories,
|
||||
// its packed artifacts) read-write, the caches beneath a throwaway layer unless write, and an environment of a PATH and
|
||||
// its packed artifacts) read-write, the caches beneath the ask's own layer unless write, and an environment of a PATH and
|
||||
// a locale alone (the step's HOME is its own, inside its /tmp).
|
||||
func (m *Module) isolation(workspace string, write bool) buildstep.Isolation {
|
||||
path := os.Getenv("PATH")
|
||||
if path == "" {
|
||||
path = defaultPath
|
||||
}
|
||||
return buildstep.Isolation{
|
||||
iso := buildstep.Isolation{
|
||||
Workspace: filepath.Join(m.Work, workspace),
|
||||
Caches: m.caches(),
|
||||
WriteCaches: write,
|
||||
Env: []string{"PATH=" + path, "LC_ALL=C"},
|
||||
}
|
||||
if !write {
|
||||
// What one command of the ask compiles is kept for its later commands (the vet, the unit tests, the integration
|
||||
// level's compile), in the workspace and gone with it (issue 1921, try speed S2).
|
||||
iso.CacheLayer = filepath.Join(iso.Workspace, cacheLayerDir)
|
||||
}
|
||||
return iso
|
||||
}
|
||||
|
||||
// cacheLayerDir is the directory in a workspace's root where an ask keeps what its commands wrote into the caches.
|
||||
const cacheLayerDir = "cachelayer"
|
||||
|
||||
// stepRunner is the runner of a run step of the workspace: buildstep.Isolated over m.Run, so every command it runs is
|
||||
// shut off; write is true for a trunk build alone (the shared caches are written by trunk builds only). Inside is the
|
||||
// integration level's own process, already running shut off (level.go): it runs its commands as they are.
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
type shutOff struct {
|
||||
flags []string
|
||||
binds, roBinds, overlays []string
|
||||
uppers map[string]string // the writable layer each --overlay gives a cache: cache -> upper
|
||||
env []string
|
||||
command string
|
||||
chdir string
|
||||
@@ -27,7 +28,7 @@ type shutOff struct {
|
||||
|
||||
// bwrapArity is how many words each bubblewrap option the SDK writes takes.
|
||||
var bwrapArity = map[string]int{"--unshare-all": 0, "--die-with-parent": 0, "--new-session": 0, "--clearenv": 0,
|
||||
"--proc": 1, "--dev": 1, "--tmpfs": 1, "--dir": 1, "--chdir": 1, "--overlay-src": 1, "--tmp-overlay": 1,
|
||||
"--proc": 1, "--dev": 1, "--tmpfs": 1, "--dir": 1, "--chdir": 1, "--overlay-src": 1, "--tmp-overlay": 1, "--overlay": 3,
|
||||
"--ro-bind": 2, "--ro-bind-try": 2, "--bind": 2, "--symlink": 2, "--setenv": 2}
|
||||
|
||||
// throughBubblewrap is the fake runner of a step's tests: a bubblewrap command is read back (recorded in seen) and its
|
||||
@@ -56,6 +57,11 @@ func throughBubblewrap(t *testing.T, run forgekey.Runner, seen *[]shutOff) forge
|
||||
s.roBinds = append(s.roBinds, args[i+1])
|
||||
case "--overlay-src":
|
||||
s.overlays = append(s.overlays, args[i+1])
|
||||
case "--overlay":
|
||||
if s.uppers == nil {
|
||||
s.uppers = map[string]string{}
|
||||
}
|
||||
s.uppers[args[i+3]] = args[i+1]
|
||||
case "--setenv":
|
||||
s.env = append(s.env, args[i+1]+"="+args[i+2])
|
||||
case "--chdir":
|
||||
@@ -105,6 +111,9 @@ func checkShutOff(t *testing.T, m *Module, s shutOff, write bool) {
|
||||
t.Errorf("a trunk build's %s does not write the shared cache %s (bound %v, layered %v)", s.command, c, bound, layered)
|
||||
case !write && (bound || !layered):
|
||||
t.Errorf("%s may write the shared cache %s (bound %v, layered %v): only a trunk build does", s.command, c, bound, layered)
|
||||
case !write && !strings.HasPrefix(s.uppers[c], filepath.Join(m.Work, "ws1", "cachelayer")+"/"):
|
||||
// 1921/S2: what one command of the ask compiles is kept for its later commands, in the workspace
|
||||
t.Errorf("%s writes the cache %s into %q, not the ask's cache layer in its workspace", s.command, c, s.uppers[c])
|
||||
}
|
||||
}
|
||||
if want := filepath.Join(m.Work, "ws1"); !slices.Contains(s.binds, want) {
|
||||
@@ -131,7 +140,8 @@ func TestTheUnitLevelRunsEveryGoTestShutOff(t *testing.T) {
|
||||
t.Fatalf("%d go commands ran, %d of them shut off: %v", ran, len(seen), *calls)
|
||||
}
|
||||
for _, s := range seen {
|
||||
checkShutOff(t, m, s, false)
|
||||
// the lint's go vet, a compile alone, writes the shared cache (issue 1921, S11): cgo_test.go
|
||||
checkShutOff(t, m, s, strings.Contains(s.command, " vet "))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ type levelSpec struct {
|
||||
Run string `json:"run,omitempty"`
|
||||
Setup time.Duration `json:"setup"`
|
||||
Bound time.Duration `json:"bound"`
|
||||
// Parallel is the ask's bound on processes, which the process shares with the ask's other level (issue 1921, S10).
|
||||
Parallel int `json:"parallel,omitempty"`
|
||||
}
|
||||
|
||||
// levelAnswer is the one line the process prints: the level's result or the digest, or why it could not run.
|
||||
@@ -83,7 +85,7 @@ func (m *Module) integrationShutOff(ctx context.Context, workspace string, packa
|
||||
// workspace read-write, the program alone read-only, the shared caches beneath a throwaway layer, the Node build
|
||||
// module's toolchains read-only for the integration level.
|
||||
func (m *Module) shutOff(ctx context.Context, exe string, spec levelSpec) (levelAnswer, error) {
|
||||
spec.Work, spec.Cache = m.Work, m.Cache
|
||||
spec.Work, spec.Cache, spec.Parallel = m.Work, m.Cache, m.parallel()
|
||||
raw, err := json.Marshal(spec)
|
||||
if err != nil {
|
||||
return levelAnswer{}, err
|
||||
@@ -165,7 +167,7 @@ func runLevelProcess(args []string) int {
|
||||
return say(levelAnswer{Error: fmt.Sprintf("%q is not a workspace id", spec.Workspace)})
|
||||
}
|
||||
m := &Module{Inside: true, Recipe: Current(), Work: spec.Work, Cache: spec.Cache, Home: os.Getenv("HOME"), UnitBound: spec.Bound,
|
||||
IntegrationBounds: Bounds{Setup: spec.Setup, Run: spec.Bound}, Say: func(string, ...any) {}}
|
||||
IntegrationBounds: Bounds{Setup: spec.Setup, Run: spec.Bound}, Parallel: spec.Parallel, Say: func(string, ...any) {}}
|
||||
m.Run = StepRunner(m.Home)
|
||||
return say(m.answer(context.Background(), spec))
|
||||
}
|
||||
|
||||
@@ -57,8 +57,14 @@ type Failure struct {
|
||||
Test string `json:"test,omitempty"`
|
||||
Output string `json:"output,omitempty"`
|
||||
Cause string `json:"cause,omitempty"`
|
||||
// Step is StepLint for a finding of the lint step (gofmt, go vet), which runs beside the unit level and is not one of
|
||||
// the levels (ADR 0542 §2): the checker reports it as the lint step's failure. Empty for a test's failure.
|
||||
Step string `json:"step,omitempty"`
|
||||
}
|
||||
|
||||
// StepLint is the Step of a lint finding.
|
||||
const StepLint = "lint"
|
||||
|
||||
// TestResult is what a test step passes with.
|
||||
type TestResult struct {
|
||||
Level string `json:"level"`
|
||||
@@ -73,7 +79,7 @@ type TestResult struct {
|
||||
Elapsed float64 `json:"elapsed"`
|
||||
// Setup is the level's setup time in seconds, when it has a setup.
|
||||
Setup float64 `json:"setup,omitempty"`
|
||||
// Lint is the unit level's gofmt and go vet time in seconds, before its tests (ADR 0529 §1).
|
||||
// Lint is the unit level's gofmt and go vet time in seconds, run beside its tests (ADR 0529 §1; issue 1921).
|
||||
Lint float64 `json:"lint,omitempty"`
|
||||
// Bound and SetupBound are the bounds the level was judged by, in seconds.
|
||||
Bound float64 `json:"bound"`
|
||||
|
||||
@@ -60,17 +60,23 @@ func lintable(dir string, each func(path string) error) error {
|
||||
})
|
||||
}
|
||||
|
||||
// unformatted are the Go files of a directory gofmt would change, relative to the tree, and the build tags they name.
|
||||
func unformatted(tree, d string) (files, tags []string, err error) {
|
||||
// unformatted are the Go files of a directory gofmt would change, relative to the tree, the build tags the directory's
|
||||
// files name, and whether a file is left out by one of them (a negated tag, //go:build !integration). Only files whose
|
||||
// directory (relative to the go.mod directory, "." for its own) is in only are checked for gofmt; nil checks every file.
|
||||
func unformatted(tree, d string, only map[string]bool) (files, tags []string, negated bool, err error) {
|
||||
seen := map[string]bool{}
|
||||
err = lintable(filepath.Join(tree, d), func(path string) error {
|
||||
base := filepath.Join(tree, d)
|
||||
err = lintable(base, func(path string) error {
|
||||
src, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if out, ferr := format.Source(src); ferr != nil || !bytes.Equal(out, src) {
|
||||
rel, _ := filepath.Rel(tree, path)
|
||||
files = append(files, filepath.ToSlash(rel))
|
||||
pkgDir, _ := filepath.Rel(base, filepath.Dir(path))
|
||||
if only == nil || only[filepath.ToSlash(pkgDir)] {
|
||||
if out, ferr := format.Source(src); ferr != nil || !bytes.Equal(out, src) {
|
||||
rel, _ := filepath.Rel(tree, path)
|
||||
files = append(files, filepath.ToSlash(rel))
|
||||
}
|
||||
}
|
||||
scan := bufio.NewScanner(bytes.NewReader(src))
|
||||
for scan.Scan() {
|
||||
@@ -79,8 +85,15 @@ func unformatted(tree, d string) (files, tags []string, err error) {
|
||||
break
|
||||
}
|
||||
if expr, ok := strings.CutPrefix(line, "//go:build "); ok {
|
||||
for _, tag := range buildTag.FindAllString(expr, -1) {
|
||||
if !notATag[tag] && !strings.HasPrefix(tag, "go1") && !seen[tag] {
|
||||
for _, at := range buildTag.FindAllStringIndex(expr, -1) {
|
||||
tag := expr[at[0]:at[1]]
|
||||
if notATag[tag] || strings.HasPrefix(tag, "go1") {
|
||||
continue
|
||||
}
|
||||
if before := strings.TrimRight(expr[:at[0]], " ("); strings.HasSuffix(before, "!") {
|
||||
negated = true
|
||||
}
|
||||
if !seen[tag] {
|
||||
seen[tag] = true
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
@@ -91,62 +104,123 @@ func unformatted(tree, d string) (files, tags []string, err error) {
|
||||
})
|
||||
sort.Strings(files)
|
||||
sort.Strings(tags)
|
||||
return files, tags, err
|
||||
return files, tags, negated, err
|
||||
}
|
||||
|
||||
// lint is gofmt and go vet of one go.mod directory d of the tree, run with run in the directory's go environment; a
|
||||
// failure of either is a Failure of the level naming what failed.
|
||||
func (m *Module) lint(ctx context.Context, run forgekey.Runner, tree, d string, env []string) ([]Failure, error) {
|
||||
files, tags, err := unformatted(tree, d)
|
||||
// boundOf is a bound of n processes of this process alone.
|
||||
func boundOf(n int) func(context.Context) (func(), error) {
|
||||
slots := make(chan struct{}, n)
|
||||
return func(context.Context) (func(), error) {
|
||||
slots <- struct{}{}
|
||||
return func() { <-slots }, nil
|
||||
}
|
||||
}
|
||||
|
||||
// lintScope is what of a go.mod directory d the lint covers, from the patterns the level runs there: every package
|
||||
// (./..., and the directories nil) when a pattern is relative or none is given, else the import paths given and the
|
||||
// directories of their files, relative to d.
|
||||
func lintScope(tree, d string, patterns []string) (targets []string, dirs map[string]bool) {
|
||||
if len(patterns) == 0 {
|
||||
return []string{"./..."}, nil
|
||||
}
|
||||
raw, _ := os.ReadFile(filepath.Join(tree, d, "go.mod"))
|
||||
mp := modulePath(string(raw))
|
||||
dirs = map[string]bool{}
|
||||
for _, p := range patterns {
|
||||
if strings.HasPrefix(p, "./") || p == "." {
|
||||
return []string{"./..."}, nil
|
||||
}
|
||||
rel, ok := strings.CutPrefix(p, mp)
|
||||
if mp == "" || !ok || (rel != "" && !strings.HasPrefix(rel, "/")) {
|
||||
return []string{"./..."}, nil // a package of no module here: the whole directory, as before
|
||||
}
|
||||
if rel = strings.TrimPrefix(rel, "/"); rel == "" {
|
||||
rel = "."
|
||||
}
|
||||
dirs[rel] = true
|
||||
targets = append(targets, p)
|
||||
}
|
||||
return targets, dirs
|
||||
}
|
||||
|
||||
// lint is gofmt and go vet of one go.mod directory d of the tree, run with run in the directory's go environment, over
|
||||
// the packages the level runs there (patterns, as packagesByDir gives them; issue 1921, try speed S3); a failure of either
|
||||
// is a Failure of the level naming what failed. go vet runs once with every tag the directory names, which vets the
|
||||
// untagged files too; only a directory where a tag leaves a file out vets also without tags, both passes at once.
|
||||
func (m *Module) lint(ctx context.Context, run forgekey.Runner, tree, d string, env, patterns []string) ([]Failure, error) {
|
||||
targets, only := lintScope(tree, d, patterns)
|
||||
files, tags, negated, err := unformatted(tree, d, only)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading the Go files of %s: %w", d, err)
|
||||
}
|
||||
var failed []Failure
|
||||
if len(files) > 0 {
|
||||
failed = append(failed, Failure{Package: d, Test: "gofmt",
|
||||
failed = append(failed, Failure{Package: d, Test: "gofmt", Step: StepLint,
|
||||
Output: "not as gofmt writes them (run gofmt -w): " + strings.Join(files, ", ")})
|
||||
}
|
||||
sets := [][]string{nil}
|
||||
if len(tags) > 0 {
|
||||
sets = append(sets, tags)
|
||||
sets := [][]string{tags}
|
||||
if len(tags) > 0 && negated {
|
||||
sets = [][]string{nil, tags}
|
||||
}
|
||||
for _, set := range sets {
|
||||
type vetRun struct {
|
||||
name string
|
||||
r forgekey.Ran
|
||||
}
|
||||
runs := make([]vetRun, len(sets))
|
||||
var wg sync.WaitGroup
|
||||
for i, set := range sets {
|
||||
args := []string{"-C", filepath.Join(tree, d), "vet"}
|
||||
name := "go vet"
|
||||
if len(set) > 0 {
|
||||
args = append(args, "-tags", strings.Join(set, ","))
|
||||
name += " -tags " + strings.Join(set, ",")
|
||||
}
|
||||
args = append(args, "./...")
|
||||
r := run(ctx, env, "go", args...)
|
||||
if r.Err != "" {
|
||||
return nil, fmt.Errorf("%s in %s could not be run: %s", name, d, firstLine(r.Err+"\n"+r.Stderr))
|
||||
args = append(args, targets...)
|
||||
runs[i].name = name
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
runs[i].r = run(ctx, env, "go", args...)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
for _, v := range runs {
|
||||
if v.r.Err != "" {
|
||||
return nil, fmt.Errorf("%s in %s could not be run: %s", v.name, d, firstLine(v.r.Err+"\n"+v.r.Stderr))
|
||||
}
|
||||
if r.Status != 0 {
|
||||
failed = append(failed, Failure{Package: d, Test: name, Output: strings.TrimSpace(r.Stderr + "\n" + r.Stdout)})
|
||||
if v.r.Status != 0 {
|
||||
failed = append(failed, Failure{Package: d, Test: v.name, Step: StepLint, Output: strings.TrimSpace(v.r.Stderr + "\n" + v.r.Stdout)})
|
||||
}
|
||||
}
|
||||
return failed, nil
|
||||
}
|
||||
|
||||
// lintAll lints each directory, up to m.parallel() at once like the level's tests; the failures join in directory order
|
||||
// and the error is the first directory's, so the answer does not depend on which ended first (issue 1930).
|
||||
func (m *Module) lintAll(ctx context.Context, run forgekey.Runner, tree string, dirs []string, env func(d string) []string) ([]Failure, error) {
|
||||
// and the error is the first directory's, so the answer does not depend on which ended first (issue 1930). byDir names
|
||||
// the packages the level runs in each directory (packagesByDir); nil lints each directory whole. acquire takes a slot of
|
||||
// the bound the lint shares with the ask's tests; nil gives it one of its own.
|
||||
func (m *Module) lintAll(ctx context.Context, run forgekey.Runner, tree string, dirs []string, env func(d string) []string, byDir map[string][]string, acquire func(context.Context) (func(), error)) ([]Failure, error) {
|
||||
type answer struct {
|
||||
failed []Failure
|
||||
err error
|
||||
}
|
||||
answers := make([]answer, len(dirs))
|
||||
slots := make(chan struct{}, m.parallel())
|
||||
if acquire == nil {
|
||||
acquire = boundOf(m.parallel())
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
var crash any // a panic of a lint goes on in the caller, as a panic of a test run does
|
||||
var crashMu sync.Mutex
|
||||
for i, d := range dirs {
|
||||
release, err := acquire(ctx)
|
||||
if err != nil {
|
||||
answers[i].err = err
|
||||
break
|
||||
}
|
||||
wg.Add(1)
|
||||
slots <- struct{}{}
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-slots }()
|
||||
defer release()
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
crashMu.Lock()
|
||||
@@ -154,7 +228,7 @@ func (m *Module) lintAll(ctx context.Context, run forgekey.Runner, tree string,
|
||||
crashMu.Unlock()
|
||||
}
|
||||
}()
|
||||
answers[i].failed, answers[i].err = m.lint(ctx, run, tree, d, env(d))
|
||||
answers[i].failed, answers[i].err = m.lint(ctx, run, tree, d, env(d), byDir[d])
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
@@ -28,8 +30,9 @@ func writeTree(t *testing.T, files map[string]string) string {
|
||||
}
|
||||
|
||||
// 1887/D7-go, ADR 0529 §1: an unformatted file fails the unit level naming the file (testdata and vendor are left
|
||||
// alone), and go vet runs with no tag and with every tag the files name together, integration and isolation included, so
|
||||
// a vet error behind isolation fails the level naming the run.
|
||||
// alone), and go vet runs with every tag the files name together, integration and isolation included, so a vet error
|
||||
// behind isolation fails the level naming the run. One pass with every tag vets the untagged files too (issue 1921, try
|
||||
// speed S3), as no file here is left out by a tag.
|
||||
func TestTheUnitLevelRunsGofmtAndVetWithEveryTag(t *testing.T) {
|
||||
tree := writeTree(t, map[string]string{
|
||||
"go.mod": "module x\n\ngo 1.26\n",
|
||||
@@ -51,11 +54,11 @@ func TestTheUnitLevelRunsGofmtAndVetWithEveryTag(t *testing.T) {
|
||||
}
|
||||
return forgekey.Ran{}
|
||||
}
|
||||
failed, err := (&Module{}).lint(context.Background(), run, tree, ".", nil)
|
||||
failed, err := (&Module{}).lint(context.Background(), run, tree, ".", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(vetted, " | ") != "vet ./... | vet -tags integration,isolation ./..." {
|
||||
if strings.Join(vetted, " | ") != "vet -tags integration,isolation ./..." {
|
||||
t.Errorf("vet ran %v", vetted)
|
||||
}
|
||||
if len(failed) != 2 || failed[0].Test != "gofmt" || !strings.Contains(failed[0].Output, "b.go") ||
|
||||
@@ -67,14 +70,14 @@ func TestTheUnitLevelRunsGofmtAndVetWithEveryTag(t *testing.T) {
|
||||
}
|
||||
// A formatted directory that vets passes.
|
||||
clean := writeTree(t, map[string]string{"go.mod": "module y\n", "a.go": "package y\n"})
|
||||
if failed, err := (&Module{}).lint(context.Background(), func(context.Context, []string, string, ...string) forgekey.Ran { return forgekey.Ran{} }, clean, ".", nil); err != nil || len(failed) != 0 {
|
||||
if failed, err := (&Module{}).lint(context.Background(), func(context.Context, []string, string, ...string) forgekey.Ran { return forgekey.Ran{} }, clean, ".", nil, nil); err != nil || len(failed) != 0 {
|
||||
t.Errorf("a clean directory: %+v, %v", failed, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1887/D7-go: through the step, the unit level of a tree holding an unformatted file fails, naming the file.
|
||||
func TestAnUnformattedFileFailsTheUnitLevel(t *testing.T) {
|
||||
m, states, ws, calls := workspaceFor(t, "empty")
|
||||
m, states, ws, _ := workspaceFor(t, "empty")
|
||||
tree := filepath.Join(m.Work, ws, "tree")
|
||||
for name, body := range map[string]string{"go.mod": "module x\n", "x.go": "package x\nfunc X() {}\n"} {
|
||||
if err := os.WriteFile(filepath.Join(tree, name), []byte(body), 0o644); err != nil {
|
||||
@@ -85,50 +88,117 @@ func TestAnUnformattedFileFailsTheUnitLevel(t *testing.T) {
|
||||
if res.Passed || len(res.Failed) != 1 || res.Failed[0].Test != "gofmt" || !strings.Contains(res.Failed[0].Output, "x.go") {
|
||||
t.Fatalf("result = %+v, want the level failed naming x.go", res)
|
||||
}
|
||||
// Review round 2 item 5: before the level's tests, which do not run once gofmt failed.
|
||||
for _, c := range *calls {
|
||||
if strings.Contains(c.line, " test -json") {
|
||||
t.Errorf("the tests ran after gofmt failed: %s", c.line)
|
||||
}
|
||||
|
||||
// 1921/S4: the lint runs beside the unit level's tests, not before them: both have started before either ends, a lint
|
||||
// failure still fails the level, and the result reports the lint's time and the tests' failures both.
|
||||
func TestTheLintRunsBesideTheUnitTestsAndItsFailureStillFailsTheLevel(t *testing.T) {
|
||||
m, states, ws, _ := workspaceFor(t, "failing")
|
||||
tree := filepath.Join(m.Work, ws, "tree")
|
||||
for name, body := range map[string]string{"go.mod": "module fixture/failing\n", "x.go": "package x\nfunc X() {}\n"} {
|
||||
if err := os.WriteFile(filepath.Join(tree, name), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
m.Parallel = 4
|
||||
inner := m.Run
|
||||
var vetStarted, testStarted sync.Once
|
||||
vet, test := make(chan struct{}), make(chan struct{})
|
||||
timeout := time.After(2 * time.Second)
|
||||
m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
||||
line := strings.Join(args, " ")
|
||||
switch {
|
||||
case strings.Contains(line, " vet "):
|
||||
vetStarted.Do(func() { close(vet) })
|
||||
select { // the vet ends only once a test has started
|
||||
case <-test:
|
||||
case <-timeout:
|
||||
t.Error("no test started while the vet ran")
|
||||
}
|
||||
case strings.Contains(line, " test -json"):
|
||||
testStarted.Do(func() { close(test) })
|
||||
select {
|
||||
case <-vet:
|
||||
case <-timeout:
|
||||
t.Error("no vet started while the tests ran")
|
||||
}
|
||||
}
|
||||
return inner(ctx, env, name, args...)
|
||||
}
|
||||
res, st := runUnit(t, m, states, ws)
|
||||
if st.State != delivery.StepPassed {
|
||||
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
|
||||
}
|
||||
if res.Passed || len(res.Failed) < 2 || res.Failed[0].Test != "gofmt" || res.Failed[1].Test != "TestThreeIsFour" {
|
||||
t.Fatalf("failed = %+v, want gofmt's failure, then the test's", res.Failed)
|
||||
}
|
||||
// ADR 0542 §2: the lint's failure is the lint step's, not the unit level's.
|
||||
if res.Failed[0].Step != StepLint || res.Failed[1].Step != "" {
|
||||
t.Errorf("steps = %q, %q; want the gofmt failure the lint step's and the test's the level's", res.Failed[0].Step, res.Failed[1].Step)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1930: the lint of many directories runs side by side up to the level's bound, not one after another, and the
|
||||
// failures still join in directory order.
|
||||
func TestTheLintOfManyDirectoriesRunsAtOnceUpToTheBound(t *testing.T) {
|
||||
files := map[string]string{}
|
||||
var dirs []string
|
||||
for _, d := range []string{"a", "b", "c", "d", "e", "f"} {
|
||||
files[d+"/go.mod"] = "module x/" + d + "\n"
|
||||
files[d+"/x.go"] = "package x\n"
|
||||
dirs = append(dirs, d)
|
||||
}
|
||||
tree := writeTree(t, files)
|
||||
var mu sync.Mutex
|
||||
running, most := 0, 0
|
||||
// 1921/S3: the lint of a level that runs some packages vets those packages alone, in one pass with every tag their
|
||||
// directory names, and reports a gofmt fault only in their files: a fault in a package the level keeps was reported
|
||||
// when that package changed.
|
||||
func TestTheLintOfSomePackagesVetsThemInOnePassAndFormatsOnlyTheirFiles(t *testing.T) {
|
||||
tree := writeTree(t, map[string]string{
|
||||
"go.mod": "module x\n\ngo 1.26\n",
|
||||
"a/a.go": "package a\n\nfunc A() {}\n",
|
||||
"a/a_integration_test.go": "//go:build integration\n\npackage a\n",
|
||||
"b/b.go": "package b\nfunc B() {}\n",
|
||||
"a/deeper/d.go": "package deeper\nfunc D() {}\n",
|
||||
})
|
||||
var vetted []string
|
||||
run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
||||
mu.Lock()
|
||||
running++
|
||||
most = max(most, running)
|
||||
mu.Unlock()
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
mu.Lock()
|
||||
running--
|
||||
mu.Unlock()
|
||||
if strings.HasSuffix(args[1], "b") || strings.HasSuffix(args[1], "e") {
|
||||
return forgekey.Ran{Status: 1, Stderr: "bad " + filepath.Base(args[1])}
|
||||
}
|
||||
vetted = append(vetted, strings.Join(args[2:], " "))
|
||||
return forgekey.Ran{}
|
||||
}
|
||||
m := &Module{Parallel: 3}
|
||||
failed, err := m.lintAll(context.Background(), run, tree, dirs, func(string) []string { return nil })
|
||||
failed, err := (&Module{}).lint(context.Background(), run, tree, ".", nil, []string{"x/a"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if most < 2 || most > 3 {
|
||||
t.Errorf("%d lint commands ran at once, want 2 or 3 (the bound)", most)
|
||||
if strings.Join(vetted, " | ") != "vet -tags integration x/a" {
|
||||
t.Errorf("vet ran %v, want one pass over x/a with its tag", vetted)
|
||||
}
|
||||
if len(failed) != 2 || failed[0].Package != "b" || failed[1].Package != "e" {
|
||||
t.Errorf("failed = %+v, want b's then e's", failed)
|
||||
if len(failed) != 0 {
|
||||
t.Errorf("the faults of packages the level keeps were reported: %+v", failed)
|
||||
}
|
||||
vetted = nil
|
||||
failed, err = (&Module{}).lint(context.Background(), run, tree, ".", nil, []string{"x/b"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(failed) != 1 || failed[0].Test != "gofmt" || !strings.Contains(failed[0].Output, "b/b.go") || strings.Contains(failed[0].Output, "d.go") {
|
||||
t.Errorf("failed = %+v, want gofmt naming b/b.go alone", failed)
|
||||
}
|
||||
if strings.Join(vetted, " | ") != "vet -tags integration x/b" {
|
||||
t.Errorf("vet ran %v", vetted)
|
||||
}
|
||||
}
|
||||
|
||||
// 1921/S3: a file a tag leaves out (//go:build !integration) is vetted by no pass with the tag, so a directory holding
|
||||
// one keeps the pass without tags beside the pass with them.
|
||||
func TestADirectoryWithAFileATagLeavesOutKeepsThePassWithoutTags(t *testing.T) {
|
||||
tree := writeTree(t, map[string]string{
|
||||
"go.mod": "module x\n",
|
||||
"a.go": "package x\n",
|
||||
"fake.go": "//go:build !integration\n\npackage x\n",
|
||||
"real_test.go": "//go:build integration\n\npackage x\n",
|
||||
})
|
||||
var mu sync.Mutex
|
||||
var vetted []string
|
||||
run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
||||
mu.Lock()
|
||||
vetted = append(vetted, strings.Join(args[2:], " "))
|
||||
mu.Unlock()
|
||||
return forgekey.Ran{}
|
||||
}
|
||||
if _, err := (&Module{}).lint(context.Background(), run, tree, ".", nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slices.Sort(vetted)
|
||||
if strings.Join(vetted, " | ") != "vet -tags integration ./... | vet ./..." {
|
||||
t.Errorf("vet ran %v, want the pass without tags and the pass with them", vetted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ func main() {
|
||||
m.RegistryFile = m.Home + "/registry"
|
||||
m.Settings = m.Home + "/settings.json"
|
||||
m.ReadKept = readOf(stdio.State(KeptState)).Get
|
||||
m.ReadManifest = readOf(stdio.State(ManifestsState)).Get
|
||||
m.Probe = ProbeGo(m.Home, m.Cache)
|
||||
m.Bubblewrap = ProbeBubblewrap
|
||||
// Outside a launch by the runtime — a build, a check — there is no node and no state to write.
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
func fetchOnce(t *testing.T, m *Module, states *keptStates, args map[string]any) FetchResult {
|
||||
@@ -188,3 +189,61 @@ func TestTheWorkspaceIsSelfContainedAfterAFetchThroughTheMirror(t *testing.T) {
|
||||
t.Errorf("the workspace is not whole: %v %s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// 1921/S6: with a base, the mirror is brought to the trunk commit too, and the merge preview deepens the tree from the
|
||||
// mirror: it asks the forge for no history the mirror holds.
|
||||
func TestTheMirrorIsBroughtToTheBaseAndThePreviewFetchesFromIt(t *testing.T) {
|
||||
f := &fakeStep{gomods: "go.mod\n", rule: previewRules(false)}
|
||||
m, states := fetchModule(t, f)
|
||||
mirror := filepath.Join(m.Cache, "mirror", "novox", "mesh-sdk.git")
|
||||
f.files = map[string]string{"modules/m/module.json": "{}\n", "modules/m/go.sum": "sum\n", "go.mod": "module x\n", "go.sum": ""}
|
||||
fetchOnce(t, m, states, map[string]any{"repository": "novox/mesh-sdk", "commit": commitSHA, "base": baseSHA})
|
||||
lines := strings.Join(f.lines(), "\n")
|
||||
if !strings.Contains(lines, "-C "+mirror+" fetch --no-tags origin "+baseSHA) {
|
||||
t.Errorf("the mirror was not brought to the base:\n%s", lines)
|
||||
}
|
||||
if !strings.Contains(lines, "fetch --no-tags --unshallow "+mirror+" "+baseSHA) || strings.Contains(lines, "--unshallow origin") {
|
||||
t.Errorf("the preview fetched the trunk's history from the forge:\n%s", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// 1921/S6: the siblings are placed side by side: each sibling's fetch has started before any of them ends.
|
||||
func TestTheSiblingsArePlacedAtOnce(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
started := map[string]bool{}
|
||||
both := make(chan struct{})
|
||||
f := &fakeStep{gomods: "go.mod\n"}
|
||||
f.rule = func(line string) (forgekey.Ran, bool) {
|
||||
if !strings.Contains(line, "/beside/") || !strings.Contains(line, " fetch --no-tags --depth 1 ") {
|
||||
return forgekey.Ran{}, false
|
||||
}
|
||||
mu.Lock()
|
||||
for _, name := range []string{"mesh-catalog", "mesh-host"} {
|
||||
if strings.Contains(line, "/beside/"+name) {
|
||||
started[name] = true
|
||||
}
|
||||
}
|
||||
if len(started) == 2 {
|
||||
select {
|
||||
case <-both:
|
||||
default:
|
||||
close(both)
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
select {
|
||||
case <-both:
|
||||
case <-time.After(2 * time.Second):
|
||||
return forgekey.Ran{Status: 1, Stderr: "the other sibling's fetch never started"}, true
|
||||
}
|
||||
return forgekey.Ran{}, false
|
||||
}
|
||||
m, states := fetchModule(t, f)
|
||||
res := fetchOnce(t, m, states, map[string]any{"repository": "novox/mesh-controller", "commit": commitSHA, "beside": []any{
|
||||
map[string]any{"repository": "novox/mesh-host", "commit": commitSHA},
|
||||
map[string]any{"repository": "novox/mesh-catalog", "commit": commitSHA},
|
||||
}})
|
||||
if len(res.Beside) != 2 || res.Beside[0].Dir != "mesh-catalog" || res.Beside[1].Dir != "mesh-host" {
|
||||
t.Errorf("beside = %+v, want both, in name order", res.Beside)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,10 @@ type Module struct {
|
||||
Settings string
|
||||
Capacity func() int
|
||||
ReadKept func(key string) (json.RawMessage, error)
|
||||
// ReadManifest reads a key of the delivery indexer's manifests state (ManifestsState), which a compose ask's request
|
||||
// names its manifests by (compose.go); GoModCache, when set, is the Go module cache in place of Cache's (a test).
|
||||
ReadManifest func(key string) (json.RawMessage, error)
|
||||
GoModCache string
|
||||
|
||||
spacesOnce sync.Once
|
||||
workspaces *buildstep.Workspaces
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
)
|
||||
@@ -34,8 +35,9 @@ func (m *Module) remote(repository string) string {
|
||||
}
|
||||
|
||||
// merge makes the merge preview of the head onto base and writes the answer into res.
|
||||
func (m *Module) merge(ctx context.Context, res *FetchResult, tree, remote, base, commit string) error {
|
||||
mr, err := buildstep.MergeInto(ctx, m.Run, tree, remote, base, commit, m.FetchOptions...)
|
||||
// opts are the fetch's options, with the mirror when it holds base.
|
||||
func (m *Module) merge(ctx context.Context, res *FetchResult, tree, remote, base, commit string, opts []buildstep.Option) error {
|
||||
mr, err := buildstep.MergeInto(ctx, m.Run, tree, remote, base, commit, opts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -89,23 +91,22 @@ func (m *Module) besideDir(workspace string) string {
|
||||
// placeSiblings fetches each sibling into beside/<name> of the workspace root, as a plain directory. A symlink anywhere
|
||||
// in a placed tree fails the step naming it: the mesh creates none, and a test must not be led out of its sibling.
|
||||
func (m *Module) placeSiblings(ctx context.Context, root string, siblings []siblingArg) ([]Sibling, error) {
|
||||
var placed []Sibling
|
||||
for _, s := range siblings {
|
||||
name := s.repository[strings.Index(s.repository, "/")+1:]
|
||||
dir := filepath.Join(root, "beside", name)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
// The siblings are placed side by side (issue 1921, try speed S6); the first failure in the order given is the step's.
|
||||
placed := make([]Sibling, len(siblings))
|
||||
errs := make([]error, len(siblings))
|
||||
var wg sync.WaitGroup
|
||||
for i, s := range siblings {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
placed[i], errs[i] = m.placeSibling(ctx, root, s)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := buildstep.FetchCommit(ctx, m.Run, dir, m.remote(s.repository), s.commit, s.ref, m.fetchOptions(ctx, s.repository, s.commit, s.ref)...); err != nil {
|
||||
return nil, fmt.Errorf("beside %s: %w", s.repository, err)
|
||||
}
|
||||
if err := m.dissociate(ctx, dir); err != nil {
|
||||
return nil, fmt.Errorf("beside %s: %w", s.repository, err)
|
||||
}
|
||||
if link := firstSymlink(dir); link != "" {
|
||||
return nil, fmt.Errorf("beside %s holds a symlink, %s", s.repository, link)
|
||||
}
|
||||
placed = append(placed, Sibling{Repository: s.repository, Commit: s.commit, Dir: name})
|
||||
}
|
||||
sort.Slice(placed, func(i, j int) bool { return placed[i].Dir < placed[j].Dir })
|
||||
if len(placed) > 0 {
|
||||
@@ -121,6 +122,25 @@ func (m *Module) placeSiblings(ctx context.Context, root string, siblings []sibl
|
||||
return placed, nil
|
||||
}
|
||||
|
||||
// placeSibling fetches one sibling into beside/<name> of the workspace root.
|
||||
func (m *Module) placeSibling(ctx context.Context, root string, s siblingArg) (Sibling, error) {
|
||||
name := s.repository[strings.Index(s.repository, "/")+1:]
|
||||
dir := filepath.Join(root, "beside", name)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return Sibling{}, err
|
||||
}
|
||||
if err := buildstep.FetchCommit(ctx, m.Run, dir, m.remote(s.repository), s.commit, s.ref, m.fetchOptions(ctx, s.repository, s.commit, s.ref)...); err != nil {
|
||||
return Sibling{}, fmt.Errorf("beside %s: %w", s.repository, err)
|
||||
}
|
||||
if err := m.dissociate(ctx, dir); err != nil {
|
||||
return Sibling{}, fmt.Errorf("beside %s: %w", s.repository, err)
|
||||
}
|
||||
if link := firstSymlink(dir); link != "" {
|
||||
return Sibling{}, fmt.Errorf("beside %s holds a symlink, %s", s.repository, link)
|
||||
}
|
||||
return Sibling{Repository: s.repository, Commit: s.commit, Dir: name}, nil
|
||||
}
|
||||
|
||||
// besideRecord is the file in a workspace's root naming the siblings the fetch placed beside it.
|
||||
const besideRecord = "beside.json"
|
||||
|
||||
|
||||
@@ -85,7 +85,8 @@ func TestFetchWithABaseMergesAndAnswersThePreview(t *testing.T) {
|
||||
t.Errorf("trees = %+v", res.Trees)
|
||||
}
|
||||
lines := strings.Join(f.lines(), "\n")
|
||||
for _, want := range []string{"fetch --no-tags --unshallow origin " + baseSHA, "checkout -q --detach " + baseSHA,
|
||||
mirror := filepath.Join(m.Cache, "mirror", "novox", "mesh-sdk.git")
|
||||
for _, want := range []string{"fetch --no-tags --unshallow " + mirror + " " + baseSHA, "checkout -q --detach " + baseSHA,
|
||||
"core.hooksPath=/dev/null", "merge --no-ff --no-edit --no-verify"} {
|
||||
if !strings.Contains(lines, want) {
|
||||
t.Errorf("git was not asked %q:\n%s", want, lines)
|
||||
@@ -290,8 +291,8 @@ func TestTheIntegrationLevelIsGivenTheBesideDirectoryOnlyWhenSiblingsWerePlaced(
|
||||
}
|
||||
tested := 0
|
||||
for _, c := range f.calls {
|
||||
if !strings.HasPrefix(c.line, "go ") || !strings.Contains(c.line, "-tags integration") {
|
||||
continue
|
||||
if !strings.HasPrefix(c.line, "go ") || !strings.Contains(c.line, "-tags integration") || strings.Contains(c.line, "-exec true") {
|
||||
continue // the setup's compile runs no test binary, so it is given nothing a test reads (issue 1921, S5)
|
||||
}
|
||||
tested++
|
||||
got := ""
|
||||
|
||||
@@ -24,9 +24,7 @@ func threeModules(t *testing.T, f *integFake) (*Module, *keptStates) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module fixture/"+d+"\n\ngo 1.25.0\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeTaggedTest(t, dir, "fixture/"+d)
|
||||
}
|
||||
return m, states
|
||||
}
|
||||
@@ -72,7 +70,7 @@ func TestOnlyTheSelectedDirectoryCompilesAndRuns(t *testing.T) {
|
||||
if !strings.HasPrefix(l, "go -C "+filepath.Join(tree, "b")+" test ") {
|
||||
t.Errorf("go test ran outside the selected directory: %s", l)
|
||||
}
|
||||
if strings.Contains(l, "-run ^$") {
|
||||
if strings.Contains(l, "-exec true") {
|
||||
compiled++
|
||||
} else {
|
||||
ran++
|
||||
@@ -91,7 +89,7 @@ func TestWithoutASelectionEveryDirectoryCompilesAndRuns(t *testing.T) {
|
||||
compiled, ran := 0, 0
|
||||
for _, l := range strings.Split(f.lines(), "\n") {
|
||||
if strings.HasPrefix(l, "go ") && strings.Contains(l, " test ") {
|
||||
if strings.Contains(l, "-run ^$") {
|
||||
if strings.Contains(l, "-exec true") {
|
||||
compiled++
|
||||
} else {
|
||||
ran++
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
// The bound on the processes one ask starts (issue 1921, try speed S10; ADR 0541): the levels of an ask run side by side,
|
||||
// the unit level in this process and the integration level in a shut-off process of its own, and they share one bound,
|
||||
// the processors divided by the node's build capacity. The bound lives in the ask's workspace as lock files, one per
|
||||
// slot, so both processes count against it.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// slotsDir is the directory in a workspace's root holding its slots.
|
||||
const slotsDir = "slots"
|
||||
|
||||
// askDirs are the directories an ask keeps in its workspace's root beside its tree, gone with the workspace: its cache
|
||||
// layer and its bound on processes.
|
||||
var askDirs = []string{cacheLayerDir, slotsDir}
|
||||
|
||||
// numCPU is the node's processor count.
|
||||
func numCPU() int { return runtime.NumCPU() }
|
||||
|
||||
// slotPool is the bound of one workspace: n slots, each a lock file under dir.
|
||||
type slotPool struct {
|
||||
dir string
|
||||
n int
|
||||
next *atomic.Uint32
|
||||
}
|
||||
|
||||
// slotTurns spreads the waits of the commands that find every slot taken over the slots.
|
||||
var slotTurns atomic.Uint32
|
||||
|
||||
// slots is the bound of the workspace's processes, m.parallel() slots.
|
||||
func (m *Module) slots(workspace string) slotPool {
|
||||
return slotPool{dir: filepath.Join(m.Work, workspace, slotsDir), n: m.parallel(), next: &slotTurns}
|
||||
}
|
||||
|
||||
// acquire takes a slot of the pool, waiting for one when every slot is taken, and answers what frees it. A free slot is
|
||||
// taken at once; when none is, the wait is on one slot, taken in turn, until its holder frees it.
|
||||
func (p slotPool) acquire(ctx context.Context) (func(), error) {
|
||||
if err := os.MkdirAll(p.dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("the workspace's bound on processes: %w", err)
|
||||
}
|
||||
take := func(i int, how int) (func(), bool, error) {
|
||||
f, err := os.OpenFile(filepath.Join(p.dir, "slot-"+strconv.Itoa(i)), os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if err := syscall.Flock(int(f.Fd()), how); err != nil {
|
||||
f.Close()
|
||||
if err == syscall.EWOULDBLOCK {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
return func() { syscall.Flock(int(f.Fd()), syscall.LOCK_UN); f.Close() }, true, nil
|
||||
}
|
||||
for i := 0; i < p.n; i++ {
|
||||
release, ok, err := take(i, syscall.LOCK_EX|syscall.LOCK_NB)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the workspace's bound on processes: %w", err)
|
||||
}
|
||||
if ok {
|
||||
return release, nil
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
release, _, err := take(int(p.next.Add(1))%p.n, syscall.LOCK_EX)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the workspace's bound on processes: %w", err)
|
||||
}
|
||||
return release, nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
// 1921/S10 (ADR 0541): the levels of one ask, running side by side, share one bound on the processes they start: the
|
||||
// bound is the workspace's, so a slot another level of the ask holds is one this level waits for.
|
||||
func TestTheLevelsOfAnAskShareOneBoundOnProcesses(t *testing.T) {
|
||||
m, states, ws, _ := workspaceFor(t, "failing")
|
||||
m.Parallel = 1
|
||||
held, err := m.slots(ws).acquire(context.Background()) // the other level holds the one slot
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ran atomic.Int32
|
||||
inner := m.Run
|
||||
m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
||||
if strings.Contains(strings.Join(args, " "), " go ") || name == "go" {
|
||||
ran.Add(1)
|
||||
}
|
||||
return inner(ctx, env, name, args...)
|
||||
}
|
||||
done := make(chan delivery.Step, 1)
|
||||
go func() { _, st := runUnit(t, m, states, ws); done <- st }()
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
if n := ran.Load(); n != 0 {
|
||||
t.Errorf("%d go commands ran while the ask's one slot was held by its other level", n)
|
||||
}
|
||||
held()
|
||||
select {
|
||||
case st := <-done:
|
||||
if st.State != delivery.StepPassed {
|
||||
t.Errorf("the step ended %s: %s", st.State, st.Reason)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("the level never ran after the slot was freed")
|
||||
}
|
||||
if ran.Load() == 0 {
|
||||
t.Error("no go command ran")
|
||||
}
|
||||
}
|
||||
|
||||
// 1921/S10 (ADR 0541): the bound is the processors divided by the node's build capacity, not each level half of them.
|
||||
func TestTheBoundIsTheProcessorsDividedByTheBuildCapacity(t *testing.T) {
|
||||
m := &Module{Capacity: func() int { return 3 }}
|
||||
if got, want := m.parallel(), max(1, numCPU()/3); got != want {
|
||||
t.Errorf("parallel = %d, want %d", got, want)
|
||||
}
|
||||
m = &Module{}
|
||||
if got, want := m.parallel(), max(1, numCPU()/2); got != want {
|
||||
t.Errorf("with no capacity known parallel = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package main
|
||||
|
||||
// The integration level runs the tests its tags add, not the unit tests again (issue 1921, try speed S7): `go test
|
||||
// -tags integration` without -run would run every untagged test too, and the unit level ran those already. Which tests a
|
||||
// tag adds is read from the source: a test function in a _test.go file whose //go:build line holds with the level's
|
||||
// tags and does not hold without them.
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/build/constraint"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// taggedRun is what the integration level runs in one go.mod directory: the packages holding a test the tags add, by
|
||||
// import path, and the -run pattern naming those tests.
|
||||
type taggedRun struct {
|
||||
packages []string
|
||||
pattern string
|
||||
}
|
||||
|
||||
// taggedTests reads, for each go.mod directory, the tests that tags add. selected are the packages the level was asked
|
||||
// to run (import paths, as build_go_digest keys them); none, or a relative pattern, is every package. A directory with no
|
||||
// test the tags add is left out of the answer.
|
||||
func taggedTests(tree string, dirs, selected, tags []string) (map[string]taggedRun, error) {
|
||||
want := map[string]bool{}
|
||||
for _, p := range selected {
|
||||
if strings.HasPrefix(p, "./") || p == "." {
|
||||
want = nil
|
||||
break
|
||||
}
|
||||
want[p] = true
|
||||
}
|
||||
if len(selected) == 0 {
|
||||
want = nil
|
||||
}
|
||||
with := map[string]bool{}
|
||||
for _, t := range tags {
|
||||
with[t] = true
|
||||
}
|
||||
out := map[string]taggedRun{}
|
||||
for _, d := range dirs {
|
||||
raw, _ := os.ReadFile(filepath.Join(tree, d, "go.mod"))
|
||||
mp := modulePath(string(raw))
|
||||
base := filepath.Join(tree, d)
|
||||
pkgs := map[string]bool{}
|
||||
names := map[string]bool{}
|
||||
err := lintable(base, func(path string) error {
|
||||
if !strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
src, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expr := buildLine(src)
|
||||
if expr == nil || !expr.Eval(func(tag string) bool { return with[tag] || platformTag(tag) }) || expr.Eval(platformTag) {
|
||||
return nil // no constraint, or one the tags do not change: the unit level's
|
||||
}
|
||||
rel, _ := filepath.Rel(base, filepath.Dir(path))
|
||||
pkg := mp
|
||||
if rel != "." {
|
||||
pkg = mp + "/" + filepath.ToSlash(rel)
|
||||
}
|
||||
if want != nil && !want[pkg] {
|
||||
return nil
|
||||
}
|
||||
found := testNames(path, src)
|
||||
for _, n := range found {
|
||||
names[n] = true
|
||||
}
|
||||
if len(found) > 0 {
|
||||
pkgs[pkg] = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(pkgs) == 0 {
|
||||
continue
|
||||
}
|
||||
run := taggedRun{packages: sortedKeys(pkgs)}
|
||||
run.pattern = "^(" + strings.Join(sortedKeys(names), "|") + ")$"
|
||||
out[d] = run
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]bool) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// buildLine is a file's //go:build constraint, nil when it has none.
|
||||
func buildLine(src []byte) constraint.Expr {
|
||||
for _, line := range strings.Split(string(src), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "package ") {
|
||||
break
|
||||
}
|
||||
if constraint.IsGoBuild(line) {
|
||||
if e, err := constraint.Parse(line); err == nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// platformTag is how a tag other than the level's evaluates, the same with the level's tags and without them: the
|
||||
// machine's platform, unix, cgo and every Go version hold; any other tag does not.
|
||||
func platformTag(tag string) bool {
|
||||
switch tag {
|
||||
case runtime.GOOS, runtime.GOARCH, "unix", "cgo", "gc":
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(tag, "go1.")
|
||||
}
|
||||
|
||||
// testNames are the top-level test functions of a test file: func TestXxx, as go test runs them, TestMain left out.
|
||||
func testNames(path string, src []byte) []string {
|
||||
f, err := parser.ParseFile(token.NewFileSet(), path, src, parser.SkipObjectResolution)
|
||||
if err != nil {
|
||||
return nil // a file that does not parse fails the compile, which the run reports
|
||||
}
|
||||
var out []string
|
||||
for _, decl := range f.Decls {
|
||||
fn, ok := decl.(*ast.FuncDecl)
|
||||
if !ok || fn.Recv != nil || fn.Name.Name == "TestMain" || !isTestName(fn.Name.Name) {
|
||||
continue
|
||||
}
|
||||
out = append(out, fn.Name.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isTestName says whether go test runs a function of this name as a test: Test, then nothing or a rune that is not
|
||||
// lower case.
|
||||
func isTestName(name string) bool {
|
||||
rest, ok := strings.CutPrefix(name, "Test")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if rest == "" {
|
||||
return true
|
||||
}
|
||||
r, _ := utf8.DecodeRuneInString(rest)
|
||||
return !unicode.IsLower(r)
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -171,6 +170,9 @@ type levelOpts struct {
|
||||
// parallelism, where the unit level's is the slowest package's.
|
||||
setup time.Duration
|
||||
wall bool
|
||||
// runByDir is the -run pattern of each go.mod directory, over run: the tests the integration level's tags add there
|
||||
// (issue 1921, try speed S7).
|
||||
runByDir map[string]string
|
||||
// compose is a state of the level to put in the result (the store and the bus it ran against).
|
||||
store, bus string
|
||||
}
|
||||
@@ -205,35 +207,56 @@ func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestR
|
||||
todo = append(todo, d)
|
||||
}
|
||||
}
|
||||
// The level's commands share one bound on processes with each other and with the ask's other level: the lint's and
|
||||
// the tests' (issue 1921, try speed S4, S10).
|
||||
slots := m.slots(o.workspace)
|
||||
var lintFailed []Failure
|
||||
var lintErr error
|
||||
var lintDone chan struct{}
|
||||
var crash any // a panic of a run or of the lint goes on in the caller, which cleans up after it
|
||||
var crashMu sync.Mutex
|
||||
if o.level == LevelUnit {
|
||||
// gofmt and go vet of each directory the level runs come before its tests (ADR 0529 §1; issue 1887 D7-go): a
|
||||
// directory that fails them fails the level at once, and their time is the result's Lint.
|
||||
linted := m.clock()
|
||||
failed, err := m.lintAll(ctx, run, tree, todo, func(d string) []string {
|
||||
return append(m.goEnvFor(filepath.Join(tree, d)), o.env...)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Lint = m.clock().Sub(linted).Seconds()
|
||||
if len(failed) > 0 {
|
||||
res.Failed = failed
|
||||
res.judge(o.bounds)
|
||||
return res, nil
|
||||
}
|
||||
began = m.clock() // the level's running time is its tests'
|
||||
// gofmt and go vet of each directory the level runs, beside its tests (ADR 0529 §1; issue 1887 D7-go; issue 1921,
|
||||
// try speed S4): a directory that fails them fails the level, and their time is the result's Lint.
|
||||
lintDone = make(chan struct{})
|
||||
go func() {
|
||||
defer close(lintDone)
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
crashMu.Lock()
|
||||
crash = p
|
||||
crashMu.Unlock()
|
||||
}
|
||||
}()
|
||||
linted := m.clock()
|
||||
// go vet compiles and runs nothing of the change, so it warms the shared cache for every later check, a pull
|
||||
// request's head included (issue 1921, try speed S11; the operator's decision); the tests run beneath the
|
||||
// ask's own layer.
|
||||
compile, err := m.stepRunner(o.workspace, true)
|
||||
if err != nil {
|
||||
lintErr = err
|
||||
return
|
||||
}
|
||||
lintFailed, lintErr = m.lintAll(ctx, compile, tree, todo, func(d string) []string {
|
||||
return append(m.goEnvFor(filepath.Join(tree, d)), o.env...)
|
||||
}, byDir, slots.acquire)
|
||||
lintFailed = append(m.addedBinaries(ctx, m.Run, tree), lintFailed...) // git alone, no code of the change
|
||||
res.Lint = m.clock().Sub(linted).Seconds()
|
||||
}()
|
||||
}
|
||||
runs := make([]dirRun, len(todo))
|
||||
slots := make(chan struct{}, m.parallel())
|
||||
var wg sync.WaitGroup
|
||||
var crash any // a panic of a run goes on in the caller, which cleans up after it
|
||||
var crashMu sync.Mutex
|
||||
var slotErr error
|
||||
for i, d := range todo {
|
||||
release, err := slots.acquire(ctx)
|
||||
if err != nil {
|
||||
slotErr = err
|
||||
break
|
||||
}
|
||||
wg.Add(1)
|
||||
slots <- struct{}{}
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-slots }()
|
||||
defer release()
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
crashMu.Lock()
|
||||
@@ -245,7 +268,9 @@ func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestR
|
||||
if len(o.tags) > 0 {
|
||||
args = append(args, "-tags", strings.Join(o.tags, ","))
|
||||
}
|
||||
if o.run != "" {
|
||||
if r := o.runByDir[d]; r != "" {
|
||||
args = append(args, "-run", r)
|
||||
} else if o.run != "" {
|
||||
args = append(args, "-run", o.run)
|
||||
}
|
||||
args = append(args, byDir[d]...)
|
||||
@@ -258,9 +283,19 @@ func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestR
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if lintDone != nil {
|
||||
<-lintDone
|
||||
}
|
||||
if crash != nil {
|
||||
panic(crash)
|
||||
}
|
||||
if lintErr != nil {
|
||||
return nil, lintErr
|
||||
}
|
||||
if slotErr != nil {
|
||||
return nil, slotErr
|
||||
}
|
||||
res.Failed = append(res.Failed, lintFailed...) // the lint's failures first, then the tests'
|
||||
for i, d := range todo {
|
||||
dr := runs[i]
|
||||
if (dr.r.Status != 0 || dr.r.Err != "") && len(dr.rd.pkgs) == 0 {
|
||||
@@ -286,12 +321,20 @@ func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestR
|
||||
// runAnswer is what a step runner answers.
|
||||
type runAnswer = forgekey.Ran
|
||||
|
||||
// parallel is how many directories a level runs at once.
|
||||
// parallel is how many processes the levels of one ask start at once, together (ADR 0541; issue 1921, try speed S10):
|
||||
// the processors divided by the node's build capacity, half of them when the capacity is not known.
|
||||
func (m *Module) parallel() int {
|
||||
if m.Parallel > 0 {
|
||||
return m.Parallel
|
||||
}
|
||||
return max(1, runtime.NumCPU()/2)
|
||||
capacity := 0
|
||||
if m.Capacity != nil {
|
||||
capacity = m.Capacity()
|
||||
}
|
||||
if capacity <= 0 {
|
||||
capacity = 2
|
||||
}
|
||||
return max(1, numCPU()/capacity)
|
||||
}
|
||||
|
||||
// packagesByDir says which patterns run in which go.mod directory. A relative pattern (./...) runs in every directory, as
|
||||
|
||||
@@ -66,7 +66,9 @@ func TestUnitLevelRunsTheRealGoOnTheFixtureRepositories(t *testing.T) {
|
||||
"tagged": {passed: true, noTests: true},
|
||||
"empty": {passed: true, noTests: true},
|
||||
"slow": {passed: true},
|
||||
"broken": {failed: "."}, // go vet, before the tests since R4g (ADR 0529 §1), names the directory and the compiler's line
|
||||
// go vet, the lint step beside the tests (ADR 0542 §2), names the directory and the compiler's line; the test run
|
||||
// of the package that does not compile fails beside it
|
||||
"broken": {failed: "."},
|
||||
}
|
||||
for name, w := range want {
|
||||
home := t.TempDir()
|
||||
@@ -84,14 +86,16 @@ func TestUnitLevelRunsTheRealGoOnTheFixtureRepositories(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
if res.Passed != w.passed || res.NoTests != w.noTests || (w.failed != "" && (len(res.Failed) != 1 || res.Failed[0].Package != w.failed)) {
|
||||
lintFirst := w.failed != "." || (len(res.Failed) >= 1 && res.Failed[0].Step == StepLint)
|
||||
if res.Passed != w.passed || res.NoTests != w.noTests || !lintFirst ||
|
||||
(w.failed != "" && (len(res.Failed) == 0 || res.Failed[0].Package != w.failed)) {
|
||||
t.Errorf("%s: result = %+v", name, res)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1887/D7-go against the real go: a tree holding an unformatted file fails the unit level naming the file, before any
|
||||
// test runs, and the level's result carries the lint's time.
|
||||
// 1887/D7-go against the real go: a tree holding an unformatted file fails the check naming the file, as the lint step's
|
||||
// failure (ADR 0542 §2), and the level's result carries the lint's time.
|
||||
func TestAnUnformattedFileFailsTheRealUnitLevel(t *testing.T) {
|
||||
needTools(t, "the unit level's gofmt and vet", "go", "git", "bwrap")
|
||||
home := t.TempDir()
|
||||
@@ -111,8 +115,9 @@ func TestAnUnformattedFileFailsTheRealUnitLevel(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Passed || len(res.Failed) == 0 || res.Failed[0].Test != "gofmt" || !strings.Contains(res.Failed[0].Output, "ugly.go") || res.Tests != 0 {
|
||||
t.Fatalf("result = %+v, want the level failed by gofmt naming ugly.go and no test run", res)
|
||||
if res.Passed || len(res.Failed) == 0 || res.Failed[0].Test != "gofmt" || res.Failed[0].Step != StepLint ||
|
||||
!strings.Contains(res.Failed[0].Output, "ugly.go") || res.Lint <= 0 {
|
||||
t.Fatalf("result = %+v, want the lint step failed by gofmt naming ugly.go, with its time", res)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,9 +161,8 @@ func TestIntegrationLevelRunsAgainstARealStoreAndBusAndLeavesNothing(t *testing.
|
||||
if !res.Passed || res.Tests != 1 || res.Setup <= 0 || res.Setup > IntegrationSetup.Seconds() {
|
||||
t.Errorf("result = %+v", res)
|
||||
}
|
||||
entries, _ := os.ReadDir(filepath.Join(work, "ws1"))
|
||||
if len(entries) != 1 || entries[0].Name() != "tree" {
|
||||
t.Errorf("the workspace holds %v, want only its tree: the run directory is gone", entries)
|
||||
if left := workspaceLeft(filepath.Join(work, "ws1")); len(left) != 1 || left[0] != "tree" {
|
||||
t.Errorf("the workspace holds %v besides the ask's own directories, want only its tree: the run directory is gone", left)
|
||||
}
|
||||
for deadline := time.Now().Add(5 * time.Second); len(processesNamed(work)) > 0 && time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) {
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -25,8 +26,11 @@ func capturedRunner(t *testing.T, captured string, calls *[]cmdRecord) forgekey.
|
||||
if strings.Contains(string(raw), `"Action":"fail"`) {
|
||||
status = 1 // go test exits 1 when a test or a build failed
|
||||
}
|
||||
var mu sync.Mutex // the lint and the tests of a level run at once (issue 1921, try speed S4)
|
||||
return func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
||||
mu.Lock()
|
||||
*calls = append(*calls, cmdRecord{env: env, line: name + " " + strings.Join(args, " ")})
|
||||
mu.Unlock()
|
||||
switch {
|
||||
case name == "git" && args[2] == "ls-files":
|
||||
return forgekey.Ran{Stdout: "go.mod\n"}
|
||||
@@ -103,6 +107,33 @@ func TestUnitLevelNamesTheFailingTestAndFailsTheLevel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1921: a fixture with a failing test in each of two packages: one level's run names both, each with its own
|
||||
// cause, and counts every test, the passing one beside a failure too. go test runs every package to its end; nothing
|
||||
// stops at the first failure.
|
||||
func TestUnitLevelNamesEveryFailingTestOfEveryPackage(t *testing.T) {
|
||||
m, states, ws, calls := workspaceFor(t, "twofailing")
|
||||
res, st := runUnit(t, m, states, ws)
|
||||
if st.State != delivery.StepPassed || res.Passed {
|
||||
t.Fatalf("step %s (%s), result %+v: want the step done and the level failed", st.State, st.Reason, res)
|
||||
}
|
||||
var got []string
|
||||
for _, f := range res.Failed {
|
||||
got = append(got, f.Package+"."+f.Test+": "+f.Cause)
|
||||
}
|
||||
want := []string{"fixture/twofailing/one.TestOneIsTwo: One() = 1, want 2", "fixture/twofailing/two.TestTwoIsThree: Two() = 2, want 3"}
|
||||
if strings.Join(got, "\n") != strings.Join(want, "\n") {
|
||||
t.Errorf("failed\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
|
||||
}
|
||||
if res.Tests != 3 {
|
||||
t.Errorf("%d tests counted, want 3", res.Tests)
|
||||
}
|
||||
for _, c := range *calls {
|
||||
if strings.Contains(c.line, " test ") && strings.Contains(c.line, "-failfast") {
|
||||
t.Errorf("go test ran with -failfast: %s", c.line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1290/AC2: a fixture whose tests take more than the bound (lowered by the module's option, not by waiting five
|
||||
// seconds): the level fails naming the bound and the slowest tests; under the real bound the same run passes.
|
||||
func TestUnitLevelOverItsBoundFailsNamingTheBoundAndTheSlowestTests(t *testing.T) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
Each file is the standard output of `GOTOOLCHAIN=go1.27.1 GOFLAGS=-mod=mod go test -count=1 -json ./...` (go1.27.1)
|
||||
run in the fixture module of the same name under ../repos, captured on 2026-10-20 for issue 1290. The unit
|
||||
tests feed them to the parser through a fake runner, so no test here runs `go test`.
|
||||
tests feed them to the parser through a fake runner, so no test here runs `go test`. twofailing.json was captured the
|
||||
same way on 2026-10-24 for issue 1921: two packages with a failing test each, and a passing test beside one of them.
|
||||
|
||||
golist-external.json is `GOTOOLCHAIN=go1.27.1 GOFLAGS=-mod=mod go list -json=ImportPath,Standard,DepOnly,Module
|
||||
git.novox.be/novox/mesh-sdk/go/buildstep` run in this module (go1.27.1, 2026-10-20 for issue 1291): a package of a module
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{"Time":"2026-10-24T23:45:37.721718705+02:00","Action":"start","Package":"fixture/twofailing/one"}
|
||||
{"Time":"2026-10-24T23:45:37.723685152+02:00","Action":"run","Package":"fixture/twofailing/one","Test":"TestOneIsTwo"}
|
||||
{"Time":"2026-10-24T23:45:37.723726472+02:00","Action":"output","Package":"fixture/twofailing/one","Test":"TestOneIsTwo","Output":"=== RUN TestOneIsTwo\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-24T23:45:37.723743682+02:00","Action":"output","Package":"fixture/twofailing/one","Test":"TestOneIsTwo","Output":" one_test.go:6: checking One\n"}
|
||||
{"Time":"2026-10-24T23:45:37.723750622+02:00","Action":"output","Package":"fixture/twofailing/one","Test":"TestOneIsTwo","Output":" one_test.go:8: One() = 1, want 2\n","OutputType":"error"}
|
||||
{"Time":"2026-10-24T23:45:37.723759302+02:00","Action":"output","Package":"fixture/twofailing/one","Test":"TestOneIsTwo","Output":"--- FAIL: TestOneIsTwo (0.00s)\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-24T23:45:37.723889303+02:00","Action":"start","Package":"fixture/twofailing/two"}
|
||||
{"Time":"2026-10-24T23:45:37.723762972+02:00","Action":"fail","Package":"fixture/twofailing/one","Test":"TestOneIsTwo","Elapsed":0}
|
||||
{"Time":"2026-10-24T23:45:37.723952274+02:00","Action":"run","Package":"fixture/twofailing/one","Test":"TestOneIsOne"}
|
||||
{"Time":"2026-10-24T23:45:37.723955484+02:00","Action":"output","Package":"fixture/twofailing/one","Test":"TestOneIsOne","Output":"=== RUN TestOneIsOne\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-24T23:45:37.723959304+02:00","Action":"output","Package":"fixture/twofailing/one","Test":"TestOneIsOne","Output":"--- PASS: TestOneIsOne (0.00s)\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-24T23:45:37.723964914+02:00","Action":"pass","Package":"fixture/twofailing/one","Test":"TestOneIsOne","Elapsed":0}
|
||||
{"Time":"2026-10-24T23:45:37.723967704+02:00","Action":"output","Package":"fixture/twofailing/one","Output":"FAIL\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-24T23:45:37.724225126+02:00","Action":"output","Package":"fixture/twofailing/one","Output":"FAIL\tfixture/twofailing/one\t0.002s\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-24T23:45:37.724231936+02:00","Action":"fail","Package":"fixture/twofailing/one","Elapsed":0.003}
|
||||
{"Time":"2026-10-24T23:45:37.725752949+02:00","Action":"run","Package":"fixture/twofailing/two","Test":"TestTwoIsThree"}
|
||||
{"Time":"2026-10-24T23:45:37.725796309+02:00","Action":"output","Package":"fixture/twofailing/two","Test":"TestTwoIsThree","Output":"=== RUN TestTwoIsThree\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-24T23:45:37.725808239+02:00","Action":"output","Package":"fixture/twofailing/two","Test":"TestTwoIsThree","Output":" two_test.go:7: Two() = 2, want 3\n","OutputType":"error"}
|
||||
{"Time":"2026-10-24T23:45:37.72581671+02:00","Action":"output","Package":"fixture/twofailing/two","Test":"TestTwoIsThree","Output":"--- FAIL: TestTwoIsThree (0.00s)\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-24T23:45:37.72582026+02:00","Action":"fail","Package":"fixture/twofailing/two","Test":"TestTwoIsThree","Elapsed":0}
|
||||
{"Time":"2026-10-24T23:45:37.72582549+02:00","Action":"output","Package":"fixture/twofailing/two","Output":"FAIL\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-24T23:45:37.726319344+02:00","Action":"output","Package":"fixture/twofailing/two","Output":"FAIL\tfixture/twofailing/two\t0.002s\n","OutputType":"frame"}
|
||||
{"Time":"2026-10-24T23:45:37.726362544+02:00","Action":"fail","Package":"fixture/twofailing/two","Elapsed":0.002}
|
||||
@@ -0,0 +1,3 @@
|
||||
module fixture/twofailing
|
||||
|
||||
go 1.25.0
|
||||
@@ -0,0 +1,3 @@
|
||||
package one
|
||||
|
||||
func One() int { return 1 }
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package one
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestOneIsTwo(t *testing.T) {
|
||||
t.Log("checking One")
|
||||
if One() != 2 {
|
||||
t.Fatalf("One() = %d, want 2", One())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneIsOne(t *testing.T) {
|
||||
if One() != 1 {
|
||||
t.Fatal("one")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package two
|
||||
|
||||
func Two() int { return 2 }
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package two
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTwoIsThree(t *testing.T) {
|
||||
if Two() != 3 {
|
||||
t.Errorf("Two() = %d, want 3", Two())
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ module meshbuildgo
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024232922-99407c37df95
|
||||
github.com/distribution/distribution/v3 v3.1.2
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93 h1:FAiFY2KBKEoZSH49OZTt7/zRBIQxyl5g/KhYrpggbN4=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024214154-bc16740feb0d h1:oJREw0ggKqJWJ5DEISSL0i2ZC5XqdXAidxo9NJ74TCc=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024214154-bc16740feb0d/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024225846-cb548a6a588c h1:GWHP9ozfkFXHVEL2gE4Xa5kc5e8uPO+UL9+mau6iaIs=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024225846-cb548a6a588c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024230839-4a12ccb78d19 h1:NqU4hkk/jHdTEcGFnnXvdOJwD8dH5u+qWaCdQQ+JYMc=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024230839-4a12ccb78d19/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024232250-9b408db24a58 h1:4Damtdqlf6u6nJhvMA99peDy1jmjixtcwFXJ4bWEkLk=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024232250-9b408db24a58/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024232922-99407c37df95 h1:I8zbgGzRjmgYydPTWo8hmaZW/P/g+qGDUfu3E32/pkU=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024232922-99407c37df95/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
}
|
||||
],
|
||||
"reads": [
|
||||
"mesh-delivery-checker.kept"
|
||||
"mesh-delivery-checker.kept",
|
||||
"mesh-delivery-indexer.manifests"
|
||||
],
|
||||
"config": {
|
||||
"capacity": {
|
||||
|
||||
@@ -3,7 +3,7 @@ module meshbuildnode
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024232922-99407c37df95
|
||||
github.com/distribution/distribution/v3 v3.1.2
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93 h1:FAiFY2KBKEoZSH49OZTt7/zRBIQxyl5g/KhYrpggbN4=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024214154-bc16740feb0d h1:oJREw0ggKqJWJ5DEISSL0i2ZC5XqdXAidxo9NJ74TCc=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024214154-bc16740feb0d/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024232922-99407c37df95 h1:I8zbgGzRjmgYydPTWo8hmaZW/P/g+qGDUfu3E32/pkU=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024232922-99407c37df95/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
|
||||
@@ -10,15 +10,15 @@ Each directory or archive this module declares says how it treats a file it did
|
||||
|
||||
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
|
||||
## A backup that failed before the send
|
||||
## What it keeps (ADR 0539 §17)
|
||||
|
||||
The controller backs up the data of the modules a delivery moves before it asks or sends anything (ADR 0404). When its
|
||||
walk's record, in `plan-moved`, names a backup that failed or stayed silent and nothing of the plan was sent, the
|
||||
delivery goes from delivering to held (the observed row `backup failed`); the held reason names the machine, the
|
||||
module and the backup's words, and the pull request's comment shows it. The hold does not lift by itself. `release`
|
||||
of such a delivery asks the controller (`delivery-backups-again`) to take the backups again and the delivery goes on
|
||||
delivering; a record from before the controller cleared the failure does not hold it again. A held delivery already
|
||||
ends as covered or delivered when a later merge covers it.
|
||||
The gate keeps the merge's states: proposed, ready, rejected, merged, held (a merge that reached the trunk without a passing
|
||||
check) and closed. It asks the operator's Approve and merges once, moving `source.main`. It asks the controller nothing, follows
|
||||
no plan event and derives nothing after the merge: whether a merged change reached the machines is the view's, from
|
||||
`source.main`, the wants and the haves. The seat `mesh-delivery` keeps the verbs `deliveries`, `show`, `groups`, `checks`,
|
||||
`table`, `stalled` (for a merge or a check past its bound), `recheck` and `close`; `times`, `what-if`, `release`, `stop` and
|
||||
`retire-history` went with the plans, so the gate stays a bundle serving the seat. A delivery an earlier owner kept in a state that
|
||||
is gone is read as the state it came to (merged, closed or proposed) when the gate starts.
|
||||
|
||||
## When a refused or rejected merge is said (ADR 0505 §4, §5)
|
||||
|
||||
@@ -33,23 +33,8 @@ names every refusal and rejection at once, and the pull request is told at once;
|
||||
`needs: new-head` and names its author: the branch, and the session a `Pushed-by: <who@node>` line in the pull
|
||||
request's description names.
|
||||
|
||||
## Taking the seat over from today's module
|
||||
## The seat's hand-over
|
||||
|
||||
The gate claims the seat `mesh-delivery` and took it over from the module `mesh-delivery`, which the catalogue no longer holds.
|
||||
The controller refuses a merge that removes a module that is assigned, so today's module stayed in the catalogue until it was
|
||||
unassigned, and went by a handover of verbs after the merge, never by hand on a machine (novox/hq ADR 0449 §4, ADR 0450 §6, ADR 0454):
|
||||
|
||||
1. assign `mesh-delivery-gate` to the machine that holds the seat (`mesh-controller.assign`);
|
||||
2. check that `mesh-controller.seats` still names `mesh-delivery`, held by the running module;
|
||||
3. unassign `mesh-delivery` from that machine (`mesh-controller.unassign`) and push it (`mesh-controller.push`);
|
||||
4. the seat passes: the gate reads the controller's assignments and the node-engine's have keys, finds the module on no machine,
|
||||
carries its deliveries, groups and pull keys over once (the key `carried` in its `deliveries` says it did), reads the controller's
|
||||
walks and the forge's open pull requests, and acts.
|
||||
|
||||
Until then the gate is silent: it takes no event, writes no key, asks nothing and answers no verb but `delivery_status`, which
|
||||
says why (`holds-the-seat`, `seat-waits`).
|
||||
|
||||
The removal of `modules/mesh-delivery` followed the handover as a pull request of its own, merged by the gate (ADR 0454); the
|
||||
catalogue has one claimant of the seat, and no manifest but the gate's and the issue tracker's names the old module but the names
|
||||
the handover needed: the three buckets the gate carries over, and the event the issue tracker and the tool runner hear
|
||||
(`catalogue_test.go`).
|
||||
The gate took the seat `mesh-delivery` over from the module `mesh-delivery` (ADR 0449 §4, ADR 0450 §6, ADR 0454) and carried its
|
||||
deliveries, groups and pull keys once; the carry, and the reading of the controller's assignments and the node-engine's have keys
|
||||
that waited for it, went with release 6. `catalogue_test.go` keeps the catalogue to one claimant of the seat.
|
||||
|
||||
@@ -1,50 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// One build per change (novox/hq ADR 0350): a pull request closed unmerged has its check builds let go of, and a
|
||||
// walk's record says per module whether its build reused a check's build or built, and why.
|
||||
const (
|
||||
cat = "novox/mesh-catalog"
|
||||
headCC = "cc00000000000001"
|
||||
headDu = "dd00000000000002"
|
||||
)
|
||||
|
||||
// A pull request closed unmerged is stopped. Since issue 1318 (ADR 0503 §3) the controller is no longer asked to let go
|
||||
// of what its checks built (§5's forget-checks goes with the merge gate's checks); a controller that is down changes
|
||||
// nothing.
|
||||
func TestAPullRequestClosedUnmergedIsStoppedAndAsksTheControllerNothing(t *testing.T) {
|
||||
w := newWorldOver(t, newMemStore(), newFakeController(), newFakeForge(), atCC)
|
||||
var atCC = time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// A pull request closed unmerged is closed. Since issue 1318 (ADR 0503 §3) the controller is no longer asked to let go
|
||||
// of what its checks built, and since ADR 0539 §17 the gate asks it nothing at all.
|
||||
func TestAPullRequestClosedUnmergedIsClosedAndAsksNoOne(t *testing.T) {
|
||||
w := newWorldOver(t, newMemStore(), newFakeForge(), atCC)
|
||||
cc := pr(cat, 101, headCC, "feat/claude-code", "modules/claude-code/module.json")
|
||||
w.h.PullUpdated(cc)
|
||||
w.h.PullClosed(cc)
|
||||
if len(w.ctl.forgot) != 0 {
|
||||
t.Fatalf("closed unmerged, the controller was asked to let go of %v", w.ctl.forgot)
|
||||
}
|
||||
if s := w.state(IDOf(cat, headCC)); s != Stopped {
|
||||
if s := w.state(IDOf(cat, headCC)); s != Closed {
|
||||
t.Fatalf("closed unmerged it is %s", s)
|
||||
}
|
||||
|
||||
w.ctl.down = true
|
||||
du := pr(cat, 102, headDu, "feat/dunst", "modules/dunst/module.json")
|
||||
w.h.PullUpdated(du)
|
||||
w.h.PullClosed(du)
|
||||
if s := w.state(IDOf(cat, headDu)); s != Stopped {
|
||||
t.Fatalf("with the controller down, a closed delivery is %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// The walk's record says per module what its build did, and the delivery keeps it beside its walk (§4).
|
||||
func TestAWalksRecordSaysWhetherEachModuleWasReusedOrBuilt(t *testing.T) {
|
||||
walk := catWalk("plan-1", mergeCC, atCC.Add(time.Minute), "claude-code", "dunst")
|
||||
walk.Modules["claude-code"].Made = "reused from the check of novox/mesh-catalog#101 at head cccccccccccc " +
|
||||
"(merged into trunk 553b71910000): tools sha256:aa"
|
||||
walk.Modules["dunst"].Made = "built: no check of novox/mesh-catalog#101 built it"
|
||||
seen := walk.Seen()
|
||||
if !strings.HasPrefix(seen.Built["claude-code"], "reused from the check of novox/mesh-catalog#101") ||
|
||||
seen.Built["dunst"] != "built: no check of novox/mesh-catalog#101 built it" {
|
||||
t.Fatalf("the delivery keeps %v", seen.Built)
|
||||
}
|
||||
if catWalk("plan-2", mergeCC, atCC, "dunst").Seen().Built != nil {
|
||||
t.Fatal("a walk from a controller that says nothing of its builds reads as if it said something")
|
||||
if s := w.state(IDOf(cat, headDu)); s != Closed {
|
||||
t.Fatalf("a closed delivery is %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// The tests of novox/hq ADR 0505 "How it is checked" for the gate: a refusal the gate is already answering is no
|
||||
@@ -76,7 +73,7 @@ func refusedForAMovedTrunk(t *testing.T) (*world, string) {
|
||||
t.Helper()
|
||||
w := announced(t)
|
||||
id := IDOf("novox/app", readyHead)
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.forge.refuse = errAskedAgain
|
||||
approve(w)
|
||||
if len(w.forge.merges) != 1 || w.delivery(id).Ready.RefusedSince == nil {
|
||||
@@ -125,7 +122,7 @@ func TestATrunkMovedRefusalIsStalledAtTwiceTheLastCheckTimeAtLeastTheFloor(t *te
|
||||
func TestAnyOtherRefusalIsStalledAtOnce(t *testing.T) {
|
||||
w := announced(t)
|
||||
id := IDOf("novox/app", readyHead)
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.forge.refuse = refusedWords("novox/app#7")
|
||||
approve(w)
|
||||
if got := linesFor(w, id); len(got) != 1 {
|
||||
@@ -146,7 +143,7 @@ func rejectedAfterItsApprove(t *testing.T, pushedBy string) (*world, string) {
|
||||
w.h.PullUpdated(p)
|
||||
w.settleAll()
|
||||
id := IDOf("novox/app", readyHead)
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
// Judged again (the table's recheck, which the checker's verdict on a new trunk makes): proposed once more.
|
||||
w.h.mu.Lock()
|
||||
d := w.h.deliveries[id]
|
||||
@@ -156,7 +153,7 @@ func rejectedAfterItsApprove(t *testing.T, pushedBy string) (*world, string) {
|
||||
}
|
||||
d.Check = nil
|
||||
w.h.mu.Unlock()
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "fail", "t1t1t1t1t1t1"))
|
||||
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "fail", "t1t1t1t1t1t1"))
|
||||
approve(w)
|
||||
w.h.React()
|
||||
if w.state(id) != Rejected {
|
||||
@@ -220,48 +217,3 @@ func TestANewHeadWithinTwoHoursKeepsTheRejectedHeadFromTheOperator(t *testing.T)
|
||||
t.Fatalf("the superseded head reached the operator: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0505 §2: a walk's end said again by the controller with its declared machines (`declared_read`, a touch marked
|
||||
// declared) acts once: no transition, comment or ask of the controller the first saying did not make.
|
||||
func TestAWalkSaidAgainWithItsDeclaredMachinesActsOnceInTheGate(t *testing.T) {
|
||||
w, id, plan := aDeliveringWalk(t)
|
||||
done := w.ctl.walk(plan)
|
||||
sent := time.Date(2026, 10, 6, 12, 40, 0, 0, time.UTC)
|
||||
done.Modules["app"] = &WalkModule{State: "built", First: []string{"anchor"}, FirstAt: &sent, SentAt: &sent, Build: "build-1",
|
||||
Gate: &WalkGate{Machines: []string{"anchor"}, Since: &sent, Passes: 1, Verdict: "passed", JudgedAt: &sent}}
|
||||
done.State = walkDone
|
||||
done = w.ctl.put(done)
|
||||
body := func(again bool) []byte {
|
||||
raw, err := json.Marshal(done)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var said map[string]any
|
||||
_ = json.Unmarshal(raw, &said)
|
||||
said["walk"], said["phase"], said["outcome"] = plan, "ended", "delivered"
|
||||
said["touches"] = []map[string]any{{"machine": "anchor", "modules": []string{"app"}}}
|
||||
if again {
|
||||
said["touches"] = append(said["touches"].([]map[string]any),
|
||||
map[string]any{"machine": "shanks", "modules": []string{}, "declared": true})
|
||||
said["declared_read"] = true
|
||||
}
|
||||
out, _ := json.Marshal(said)
|
||||
return out
|
||||
}
|
||||
if err := take(w.h, stdio.Envelope{Key: ControllerSeat + ".plan-moved", Body: body(false)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.settleAll()
|
||||
if s := w.state(id); s != Delivered {
|
||||
t.Fatalf("the walk's end left it %s", s)
|
||||
}
|
||||
transitions, comments, delivers := len(w.delivery(id).Transitions), len(w.forge.comments), len(w.ctl.delivers)
|
||||
if err := take(w.h, stdio.Envelope{Key: ControllerSeat + ".plan-moved", Body: body(true)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.settleAll()
|
||||
if len(w.delivery(id).Transitions) != transitions || len(w.forge.comments) != comments || len(w.ctl.delivers) != delivers {
|
||||
t.Fatalf("said again it acted again: transitions %d→%d, comments %d→%d, asks of the controller %d→%d",
|
||||
transitions, len(w.delivery(id).Transitions), comments, len(w.forge.comments), delivers, len(w.ctl.delivers))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,672 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Which merges a walk answers (novox/hq issue 362, ADR 0276).
|
||||
//
|
||||
// On 2026-10-10 two merges of the catalogue 18 seconds apart were walked by one walk at the later merge's
|
||||
// commit, which superseded the first. This owner found the walk a delivery follows by the walk's id or by the
|
||||
// commit it was merged as, final or not: the surviving walk matched the dunst delivery, which the supersession
|
||||
// had already ended, so nothing gave it its word, and the claude-code delivery, merged as another commit,
|
||||
// matched no walk and was held for a person after ten minutes, for a reason that named neither.
|
||||
//
|
||||
// A walk answers every merge it carries: the merges it names (`delivery.merges`, said by a controller that
|
||||
// batches merges), the merge of its own commit, a merge whose walk it took over, and a merge of its repository
|
||||
// and branch the forge says its commit contains. A delivery follows the newest walk that answers it, and only a
|
||||
// delivery not yet ended is ever tied to a walk by what it answers: an ended delivery is moved only by the
|
||||
// record of its own walk, which no longer moves it.
|
||||
|
||||
// seenWalk is a walk's last record and when it was heard.
|
||||
type seenWalk struct {
|
||||
walk Walk
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// keptWalkFor is how long a walk no longer open is kept for a merge heard after it ended; a batch is kept as
|
||||
// long, past its window's latest close.
|
||||
const keptWalkFor = 10 * time.Minute
|
||||
|
||||
// answer is a delivery a walk answers and the commit of its repository the walk carries for it.
|
||||
type answer struct {
|
||||
d *Delivery
|
||||
carried string
|
||||
}
|
||||
|
||||
// walkAnswers is whether a walk answers a delivery's merge, and the commit it carries for it.
|
||||
func walkAnswers(w Walk, d *Delivery) (string, bool) {
|
||||
if w.Release != nil || w.IsBatch() || d.MergedAs == "" {
|
||||
return "", false
|
||||
}
|
||||
if c, ok := w.carriedFor(d.Repository, d.MergedAs); ok {
|
||||
return c, true
|
||||
}
|
||||
if d.Walk != nil && d.Walk.TakenOverBy == w.ID && namesNoMerges(w) && strings.EqualFold(w.Repository, d.Repository) &&
|
||||
sameBranch(w.Branch, d.Base) {
|
||||
// The walk that took its walk over, from a controller that names no merges: it answers every merge the one
|
||||
// it took over answered, by its commit of the same repository and branch. A walk that names its merges
|
||||
// answers only those (carriedFor, above).
|
||||
return w.Commit, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// containedAnswer is, for a controller that does not name the merges a walk answers, whether an open walk
|
||||
// answers a ready merged delivery by containment: of its repository and branch, made for a merge after it, the
|
||||
// forge says its commit contains this merge, and it moves every module this merge's plan moves. A later walk that
|
||||
// moves only what its own merge moved does not deliver this one, however much of the trunk it contains.
|
||||
func (h *Holder) containedAnswer(w Walk, d *Delivery) (string, bool) {
|
||||
if !h.mayContain(w, d) || !d.ContainedIn[w.Commit] {
|
||||
return "", false
|
||||
}
|
||||
return w.Commit, true
|
||||
}
|
||||
|
||||
// mayContain is whether an open walk is one to ask the forge about for a ready merged delivery (containedAnswer).
|
||||
func (h *Holder) mayContain(w Walk, d *Delivery) bool {
|
||||
if w.Release != nil || w.IsBatch() || !w.Open() || !namesNoMerges(w) || d.State != Ready || d.MergedAs == "" || d.Walk != nil ||
|
||||
d.Plan == nil || len(d.Plan.Moved) == 0 || !strings.EqualFold(w.Repository, d.Repository) ||
|
||||
!sameBranch(w.Branch, d.Base) || sameCommit(w.Commit, d.MergedAs) {
|
||||
return false
|
||||
}
|
||||
for _, m := range d.Plan.Moved {
|
||||
if _, moves := w.Modules[m]; !moves {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Made for a merge after this one: the walk's own merge, where this owner holds it, or else the walk itself.
|
||||
after := w.Created
|
||||
for _, o := range h.deliveries {
|
||||
if strings.EqualFold(o.Repository, w.Repository) && sameCommit(o.MergedAs, w.Commit) && !o.MergedAt.IsZero() {
|
||||
after = o.MergedAt
|
||||
}
|
||||
}
|
||||
return after.After(d.MergedAt)
|
||||
}
|
||||
|
||||
// namesNoMerges is whether a walk is from a controller that does not name the merges it answers.
|
||||
func namesNoMerges(w Walk) bool { return w.Delivery == nil || len(w.Delivery.Merges) == 0 }
|
||||
|
||||
func sameBranch(a, b string) bool { return a == "" || b == "" || strings.EqualFold(a, b) }
|
||||
|
||||
// follows is whether a delivery takes a walk that answers it as the one it follows: it has none, it is that walk,
|
||||
// that walk took its own over, or that walk is newer than the one it follows. A walk record kept before its
|
||||
// creation was read is followed past only once it is superseded or failed.
|
||||
func follows(d *Delivery, w Walk) bool {
|
||||
switch {
|
||||
case d.Walk == nil || d.Walk.ID == w.ID || d.Walk.TakenOverBy == w.ID:
|
||||
return true
|
||||
case d.Walk.Created.IsZero():
|
||||
return d.Walk.State == walkSuperseded || d.Walk.State == walkFailed
|
||||
}
|
||||
return w.Created.After(d.Walk.Created)
|
||||
}
|
||||
|
||||
// deliveriesWalking are the deliveries a walk record moves: every delivery that already follows it — ended or
|
||||
// not, its own record — and every delivery not ended that it answers and that follows it now. In id order.
|
||||
func (h *Holder) deliveriesWalking(w Walk) []answer {
|
||||
var out []answer
|
||||
for _, d := range h.deliveries {
|
||||
if d.Walk != nil && d.Walk.ID == w.ID {
|
||||
carried := d.AnsweredBy
|
||||
if c, ok := walkAnswers(w, d); ok {
|
||||
carried = c
|
||||
}
|
||||
out = append(out, answer{d: d, carried: carried})
|
||||
continue
|
||||
}
|
||||
if d.State.Final() || !follows(d, w) {
|
||||
continue
|
||||
}
|
||||
if c, ok := walkAnswers(w, d); ok {
|
||||
out = append(out, answer{d: d, carried: c})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].d.ID < out[j].d.ID })
|
||||
return out
|
||||
}
|
||||
|
||||
// accounted is whether any delivery, ended or not, follows the walk or is answered by it: a walk known for
|
||||
// what it answers is never made a delivery of its own.
|
||||
func (h *Holder) accounted(w Walk) bool {
|
||||
for _, d := range h.deliveries {
|
||||
if d.Walk != nil && d.Walk.ID == w.ID {
|
||||
return true
|
||||
}
|
||||
if _, ok := walkAnswers(w, d); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// held is whether a live delivery follows the walk, or a live delivery it answers follows a newer one. A walk whose
|
||||
// word only stops that lapsed give (lapsedOn, novox/hq issue 753) is not held: it stays under the orphans' timer, so
|
||||
// past the grace without its word it is listed by `stalled`, saying why it was not given.
|
||||
func (h *Holder) heldWalk(w Walk) bool {
|
||||
for _, d := range h.deliveries {
|
||||
if d.State.Final() {
|
||||
continue
|
||||
}
|
||||
if d.Walk != nil && d.Walk.ID == w.ID {
|
||||
return true
|
||||
}
|
||||
if _, ok := walkAnswers(w, d); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pickWalk is the newest walk heard that answers a merged delivery with no walk yet; nil for none.
|
||||
func (h *Holder) pickWalk(d *Delivery) (Walk, string, bool) {
|
||||
var best Walk
|
||||
var carried string
|
||||
found := false
|
||||
for _, s := range h.walks {
|
||||
if !follows(d, s.walk) {
|
||||
continue
|
||||
}
|
||||
c, ok := walkAnswers(s.walk, d)
|
||||
if !ok {
|
||||
c, ok = h.containedAnswer(s.walk, d)
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !found || s.walk.Created.After(best.Created) || s.walk.Created.Equal(best.Created) && s.walk.ID > best.ID {
|
||||
best, carried, found = s.walk, c, true
|
||||
}
|
||||
}
|
||||
return best, carried, found
|
||||
}
|
||||
|
||||
// linkAnswering ties a merged delivery with no walk to the newest walk heard that answers it; under the lock.
|
||||
func (h *Holder) linkAnswering(d *Delivery) bool {
|
||||
if d.State.Final() || d.MergedAs == "" || d.Walk != nil {
|
||||
return false
|
||||
}
|
||||
w, carried, ok := h.pickWalk(d)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
h.linkWalk(d, w, carried)
|
||||
delete(h.unmatched, w.ID)
|
||||
return true
|
||||
}
|
||||
|
||||
// containsAsks are the forge's asks whether an open walk of a merged delivery's repository and branch, at
|
||||
// another commit, contains its merge: for a controller that does not name the merges a walk answers (issue 362),
|
||||
// asked once per pair, the answer kept on the delivery.
|
||||
func (h *Holder) containsAsks(d *Delivery, now time.Time) []func() {
|
||||
var asks []func()
|
||||
for _, s := range h.walks {
|
||||
w := s.walk
|
||||
if !h.mayContain(w, d) {
|
||||
continue
|
||||
}
|
||||
if _, known := d.ContainedIn[w.Commit]; known {
|
||||
continue
|
||||
}
|
||||
if a := h.askContains(d, w.Commit, now); a != nil {
|
||||
asks = append(asks, a)
|
||||
}
|
||||
}
|
||||
return asks
|
||||
}
|
||||
|
||||
// batchOf is the batch a merged delivery's merge is in, when the controller said one; false for none.
|
||||
func (h *Holder) batchOf(d *Delivery) (Walk, bool) {
|
||||
if d.MergedAs == "" {
|
||||
return Walk{}, false
|
||||
}
|
||||
for _, s := range h.walks {
|
||||
if !s.walk.IsBatch() || s.walk.Delivery == nil {
|
||||
continue
|
||||
}
|
||||
for _, m := range s.walk.Delivery.Merges {
|
||||
if strings.EqualFold(m.Repository, d.Repository) && sameCommit(m.Commit, d.MergedAs) {
|
||||
return s.walk, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return Walk{}, false
|
||||
}
|
||||
|
||||
// batchSaid is what a batch holding a delivery's merge waits for, in words.
|
||||
func batchSaid(b Walk, now time.Time) string {
|
||||
said := "its batch " + b.ID + ": " + b.State
|
||||
bt := b.Delivery.Batch
|
||||
switch {
|
||||
case b.State == walkQueued && bt != nil && bt.Behind != "":
|
||||
said += " behind " + bt.Behind
|
||||
case b.State == walkAssembling && bt != nil && bt.ClosesAt != nil:
|
||||
left := bt.ClosesAt.Sub(now).Round(time.Second)
|
||||
if left < 0 {
|
||||
left = 0
|
||||
}
|
||||
said += fmt.Sprintf(", %s left", left)
|
||||
if bt.AtMost != nil {
|
||||
said += " (at the latest " + bt.AtMost.Local().Format("15:04:05") + ")"
|
||||
}
|
||||
}
|
||||
return said + "; plan not yet calculated"
|
||||
}
|
||||
|
||||
// unwalkedWhy is why a merged delivery no walk answers is held: the open walks of its repository and that none
|
||||
// answers its merge, or that the controller holds none — what happened, not what might have.
|
||||
func (h *Holder) unwalkedWhy(d *Delivery) string {
|
||||
var open []string
|
||||
for _, s := range h.walks {
|
||||
w := s.walk
|
||||
if w.Release == nil && !w.IsBatch() && w.Open() && strings.EqualFold(w.Repository, d.Repository) {
|
||||
open = append(open, fmt.Sprintf("%s at %s", w.ID, shortOf(w.Commit, 8)))
|
||||
}
|
||||
}
|
||||
sort.Strings(open)
|
||||
head := fmt.Sprintf("merged as %s, and no walk answers its merge within %s: ", shortOf(d.MergedAs, 8), unwalked)
|
||||
if len(open) == 0 {
|
||||
return head + "the controller holds no open walk of " + d.Repository + " — its merge was not heard, or " +
|
||||
"nothing the mesh holds follows " + orNone(d.Base) + ": a person decides"
|
||||
}
|
||||
return head + "the open walk(s) of " + d.Repository + " — " + strings.Join(open, ", ") + " — name no merge of " +
|
||||
"it and the forge did not say they contain it: a person decides"
|
||||
}
|
||||
|
||||
func orNone(s string) string {
|
||||
if s == "" {
|
||||
return "its branch"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// orphanLines are the waiting walks no live delivery holds, past the grace a merge is given to be heard, as
|
||||
// stalled lines in the state Unheld: nothing would ever give such a walk its word (issue 362). Under the lock.
|
||||
func (h *Holder) orphanLines(now time.Time) []StalledLine {
|
||||
var out []StalledLine
|
||||
for id, first := range h.orphans {
|
||||
s, known := h.walks[id]
|
||||
if !known || now.Sub(first) < matchGrace {
|
||||
continue
|
||||
}
|
||||
w := s.walk
|
||||
if len(h.lapsedOn(w)) > 0 {
|
||||
// Owed its word by stops that lapsed, and not given it past the grace (review of issue 753): said, why.
|
||||
says := fmt.Sprintf("the walk %s of %s at %s waits for this owner's word, which the stopped deliveries whose "+
|
||||
"merge it answers owe it (novox/hq issue 753), and it has not started", w.ID, w.Repository, shortOf(w.Commit, 8))
|
||||
if why := h.lapsedSaid(w); why != "" {
|
||||
says += ": " + why
|
||||
}
|
||||
out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Published, Walk: w.ID,
|
||||
For: now.Sub(first).Round(time.Second).String(), Bound: matchGrace.String(),
|
||||
H2: "none: its word is given at its turn — the operator's past the bound", Says: says})
|
||||
continue
|
||||
}
|
||||
out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Unheld, Walk: w.ID,
|
||||
For: now.Sub(first).Round(time.Second).String(), Bound: matchGrace.String(),
|
||||
H2: "none: no delivery holds the walk, so nothing gives it its word — the operator's",
|
||||
Says: fmt.Sprintf("the walk %s of %s at %s waits for this owner's word, and no delivery that has not ended "+
|
||||
"follows it or is answered by it: `mesh-delivery.show` for the deliveries of %s, and `plans go %s --why …` "+
|
||||
"starts it by hand", w.ID, w.Repository, shortOf(w.Commit, 8), w.Repository, w.ID)})
|
||||
out[len(out)-1].Says += h.endedSaid(w)
|
||||
if hold := h.coverHeld(id); hold != "" {
|
||||
out[len(out)-1].Says += "; it is held back meanwhile: " + hold
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Unheld is a waiting walk no live delivery holds: not a state of the table — only what a stalled line says.
|
||||
const Unheld State = "unheld"
|
||||
|
||||
// seeOrphans keeps when each waiting walk no live delivery holds was first seen so, and forgets the rest; a
|
||||
// walk still unmatched waits for its merge or its adoption instead. Under the lock.
|
||||
func (h *Holder) seeOrphans(now time.Time) {
|
||||
for id, s := range h.walks {
|
||||
w := s.walk
|
||||
_, unmatched := h.unmatched[id]
|
||||
if unmatched || w.Release != nil || w.IsBatch() || !w.Open() || !w.Seen().Waits || h.heldWalk(w) {
|
||||
delete(h.orphans, id)
|
||||
continue
|
||||
}
|
||||
if _, was := h.orphans[id]; !was {
|
||||
h.orphans[id] = now
|
||||
h.Logf("[mesh-delivery] the walk %s waits for this owner's word and no live delivery holds it: said once "+
|
||||
"it is %s so", id, matchGrace)
|
||||
}
|
||||
}
|
||||
for id := range h.orphans {
|
||||
if _, known := h.walks[id]; !known {
|
||||
delete(h.orphans, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// forgetWalks drops a walk record no longer open after keptWalkFor, and a batch heard of that long past its
|
||||
// window's latest close. Under the lock.
|
||||
func (h *Holder) forgetWalks(now time.Time) {
|
||||
for id, s := range h.walks {
|
||||
w := s.walk
|
||||
switch {
|
||||
case w.IsBatch():
|
||||
end := s.at
|
||||
if w.Delivery != nil && w.Delivery.Batch != nil && w.Delivery.Batch.AtMost != nil && w.Delivery.Batch.AtMost.After(end) {
|
||||
end = *w.Delivery.Batch.AtMost
|
||||
}
|
||||
if now.Sub(end) > 3*time.Hour {
|
||||
delete(h.walks, id)
|
||||
}
|
||||
case !w.Open() && now.Sub(s.at) > keptWalkFor:
|
||||
delete(h.walks, id)
|
||||
delete(h.deliverErr, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- the word, once per walk ------------------------------------------------------------------------------
|
||||
|
||||
// turnWant is a walk whose turn came for some of the deliveries it carries, and why.
|
||||
type turnWant struct {
|
||||
ids []string
|
||||
whys []string
|
||||
}
|
||||
|
||||
// endedSaid names the ended deliveries a waiting walk answers or that follow it, with their states, so a walk no live
|
||||
// delivery holds says what it carries instead of only that nothing holds it (novox/hq issue 753); empty for none.
|
||||
func (h *Holder) endedSaid(w Walk) string {
|
||||
var ended []string
|
||||
for _, d := range h.deliveries {
|
||||
if !d.State.Final() {
|
||||
continue
|
||||
}
|
||||
_, answers := walkAnswers(w, d)
|
||||
if answers || d.Walk != nil && d.Walk.ID == w.ID {
|
||||
ended = append(ended, fmt.Sprintf("%s (%s)", d.ID, d.State))
|
||||
}
|
||||
}
|
||||
if len(ended) == 0 {
|
||||
return ""
|
||||
}
|
||||
sort.Strings(ended)
|
||||
return "; every delivery it carries ended: " + strings.Join(ended, ", ")
|
||||
}
|
||||
|
||||
// lapsedOn are the deliveries, by id, whose word an open walk waiting for this owner's word takes from a stop that
|
||||
// lapsed (novox/hq issue 753): each was stopped by its group for a member delivered since, so its own change waits for
|
||||
// a later walk of its repository that contains its merge (issue 747), and this walk answers that merge — it names it,
|
||||
// or is made at it — and has not left it out. Such a walk is the one that delivery waits for: it gives it its word, so
|
||||
// a walk whose every other delivery ended is never left without one. A walk at the merge of a delivery stopped for
|
||||
// itself, or by a person, gets nothing from it. Under the lock.
|
||||
//
|
||||
// No member is ordered before another (novox/hq ADR 0418 §2): each such delivery gives its word at once.
|
||||
func (h *Holder) lapsedOn(w Walk) []string {
|
||||
if w.Release != nil || w.IsBatch() || !w.Open() || !w.Seen().Waits {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, d := range h.deliveries {
|
||||
if h.lapsedAnswered(w, d) {
|
||||
out = append(out, d.ID)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// lapsedAnswered is whether a delivery's stop lapsed and the walk answers its merge, not left out.
|
||||
func (h *Holder) lapsedAnswered(w Walk, d *Delivery) bool {
|
||||
if d.MergedAs == "" || !h.stopLapsed(d) {
|
||||
return false
|
||||
}
|
||||
if _, ok := walkAnswers(w, d); !ok {
|
||||
return false
|
||||
}
|
||||
_, left := w.leftOut(d.Repository, d.MergedAs)
|
||||
return !left
|
||||
}
|
||||
|
||||
// lapsedSaid is why a walk whose word stops that lapsed give still waits: the members of their groups not yet
|
||||
// delivered before them, what the last look held it for, or the controller's refusal of the word. Under the lock.
|
||||
func (h *Holder) lapsedSaid(w Walk) string {
|
||||
var parts []string
|
||||
if why := h.waitsWhy[w.ID]; why != "" {
|
||||
parts = append(parts, "not let go yet: "+why)
|
||||
}
|
||||
if err := h.deliverErr[w.ID]; err != "" {
|
||||
parts = append(parts, "the controller refused its word: "+err)
|
||||
}
|
||||
for _, id := range h.lapsedOn(w) {
|
||||
if d := h.deliveries[id]; d != nil && strings.Contains(d.LetGoWhy, lapsedWord(w.ID)) {
|
||||
parts = append(parts, fmt.Sprintf("its word was given for %s at %s, and the controller has not started it",
|
||||
id, d.LetGo.Local().Format("15:04:05")))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// lapsedOwed is whether a walk's word is owed by stops that lapsed.
|
||||
func (h *Holder) lapsedOwed(w Walk) bool {
|
||||
return len(h.lapsedOn(w)) > 0
|
||||
}
|
||||
|
||||
// lapsedTurns registers, for every open walk waiting for this owner's word, the turn of each delivery whose stop
|
||||
// lapsed and whose merge it answers (lapsedOn, novox/hq issue 753). The word is then given as for any walk: once,
|
||||
// when every live delivery it carries is at its turn too and it carries no stopped change still held back (words).
|
||||
// Given once per walk: the walk's id is in the delivery's LetGoWhy once its word was given. Under the lock.
|
||||
func (h *Holder) lapsedTurns() {
|
||||
for id, s := range h.walks {
|
||||
for _, did := range h.lapsedOn(s.walk) {
|
||||
d := h.deliveries[did]
|
||||
if strings.Contains(d.LetGoWhy, lapsedWord(id)) {
|
||||
continue
|
||||
}
|
||||
if h.turning == nil {
|
||||
h.turning = map[string]*turnWant{}
|
||||
}
|
||||
want := h.turning[id]
|
||||
if want == nil {
|
||||
want = &turnWant{}
|
||||
h.turning[id] = want
|
||||
}
|
||||
if !containsString(want.ids, d.ID) {
|
||||
want.ids = append(want.ids, d.ID)
|
||||
want.whys = append(want.whys, fmt.Sprintf("%s: %s its merge %s, stopped by its group for %s, whose change "+
|
||||
"was delivered since; it waits for a later walk of %s that contains its merge, and this is that "+
|
||||
"walk (novox/hq issue 753)", d.ID, lapsedWord(id), shortOf(d.MergedAs, 8), d.stoppedFor(), d.Repository))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// lapsedWord is how the word a lapsed stop gives a walk names that walk.
|
||||
func lapsedWord(walk string) string { return "the walk " + walk + " answers" }
|
||||
|
||||
// words are the asks of the word of every walk whose turn came for every live delivery it carries: one ask per
|
||||
// walk, however many deliveries it carries, and none while one of them is not at its turn (ADR 0276 decision 5).
|
||||
// Under the lock.
|
||||
func (h *Holder) words() []func() {
|
||||
walks := make([]string, 0, len(h.turning))
|
||||
for walk := range h.turning {
|
||||
walks = append(walks, walk)
|
||||
}
|
||||
sort.Strings(walks)
|
||||
var asks []func()
|
||||
h.waitsWhy = map[string]string{}
|
||||
for _, walk := range walks {
|
||||
want := h.turning[walk]
|
||||
var waiting []string
|
||||
for _, d := range h.deliveries {
|
||||
if d.State.Final() || d.Walk == nil || d.Walk.ID != walk || containsString(want.ids, d.ID) {
|
||||
continue
|
||||
}
|
||||
waiting = append(waiting, fmt.Sprintf("%s (%s)", d.ID, d.State))
|
||||
}
|
||||
if len(waiting) > 0 {
|
||||
sort.Strings(waiting)
|
||||
h.waitsWhy[walk] = strings.Join(waiting, ", ") + " carried by it not at its turn"
|
||||
h.Logf("[mesh-delivery] the walk %s is not let go yet: %s", walk, h.waitsWhy[walk])
|
||||
continue
|
||||
}
|
||||
ids, why := append([]string(nil), want.ids...), strings.Join(want.whys, "; ")
|
||||
// **Never the word for a walk that carries a stopped change** (ADR 0299, review of #221): it is asked to
|
||||
// leave the merge out first (leaveStopped).
|
||||
if stopped, _ := h.carriesStopped(walk); len(stopped) > 0 {
|
||||
h.waitsWhy[walk] = "it carries " + strings.Join(stopped, ", ") + ", whose delivery was stopped: it is " +
|
||||
"asked to leave that merge out first"
|
||||
if hold := h.coverHeld(walk); hold != "" {
|
||||
h.waitsWhy[walk] = hold
|
||||
}
|
||||
h.Logf("[mesh-delivery] the walk %s is not let go: it carries %s, whose delivery was stopped", walk,
|
||||
strings.Join(stopped, ", "))
|
||||
continue
|
||||
}
|
||||
if h.asking[walk] {
|
||||
continue // its word is being asked
|
||||
}
|
||||
h.asking[walk] = true
|
||||
walk := walk
|
||||
asks = append(asks, func() {
|
||||
err := h.Controller.Deliver(walk, why)
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
delete(h.asking, walk)
|
||||
if err != nil {
|
||||
if h.deliverErr == nil {
|
||||
h.deliverErr = map[string]string{}
|
||||
}
|
||||
h.deliverErr[walk] = err.Error() // said by `stalled` past the grace (review of novox/hq issue 753)
|
||||
h.Logf("[mesh-delivery] the walk %s could not be let go yet: %v", walk, err)
|
||||
h.again("a walk's word the controller refused or did not answer")
|
||||
return
|
||||
}
|
||||
delete(h.deliverErr, walk)
|
||||
for _, id := range ids {
|
||||
d := h.deliveries[id]
|
||||
if d == nil {
|
||||
continue
|
||||
}
|
||||
d.LetGo, d.LetGoWhy = h.Now(), why
|
||||
if d.Walk != nil && d.Walk.ID == walk {
|
||||
d.Walk.Waits, d.Walk.GoBy = false, byOwner
|
||||
}
|
||||
h.settleAndKeep(d)
|
||||
}
|
||||
})
|
||||
}
|
||||
h.turning = nil
|
||||
return asks
|
||||
}
|
||||
|
||||
// endedMovingNothing is whether a delivery ended delivered with nothing for a walk to move: merged, its plan
|
||||
// moving no module the mesh holds, and no walk followed.
|
||||
func endedMovingNothing(d *Delivery) bool {
|
||||
return d.State == Delivered && d.MergedAs != "" && d.Walk == nil && d.MovesNothing()
|
||||
}
|
||||
|
||||
// endedAsNothingToMove are the deliveries a walk answers that ended delivered as nothing for a walk to move (novox/hq
|
||||
// issues 362, 529: the nzbgeek case); empty when it answers any other. Such a walk is held by nothing: said, never
|
||||
// let go by this owner. Under the lock.
|
||||
func (h *Holder) endedAsNothingToMove(w Walk) []string {
|
||||
var out []string
|
||||
for _, d := range h.deliveries {
|
||||
_, answers := walkAnswers(w, d)
|
||||
if !answers && (d.Walk == nil || d.Walk.ID != w.ID) {
|
||||
continue
|
||||
}
|
||||
if !endedMovingNothing(d) {
|
||||
return nil
|
||||
}
|
||||
out = append(out, d.ID)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// WalksWaiting is `stalled` asked with `walks` (novox/hq issue 529): for each walk waiting for this owner's word,
|
||||
// what holds it, in the shape of a stalled line naming the walk — each held delivery it carries (State held, its
|
||||
// reason and the verb that releases it), else why its word was not given at the last look (State published), else
|
||||
// that no live delivery holds it (State unheld). A walk whose word is being given is not listed. The controller's
|
||||
// `plans` says these instead of pointing at a hand start; the self-check never raises a condition for them.
|
||||
func (h *Holder) WalksWaiting() []StalledLine {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
now := h.Now()
|
||||
var out []StalledLine
|
||||
for id, s := range h.walks {
|
||||
w := s.walk
|
||||
if w.Release != nil || w.IsBatch() || !w.Open() || !w.Seen().Waits || h.asking[id] {
|
||||
continue
|
||||
}
|
||||
waited := now.Sub(w.Created).Round(time.Second).String()
|
||||
var held []StalledLine
|
||||
live := false
|
||||
for _, d := range h.deliveries {
|
||||
if d.State.Final() || d.Walk == nil || d.Walk.ID != id {
|
||||
continue
|
||||
}
|
||||
live = true
|
||||
if d.State == Held {
|
||||
held = append(held, StalledLine{ID: d.ID, Number: d.Number, State: Held, Walk: id, For: waited,
|
||||
Bound: "0s", H2: "none: held for a person — the operator's", Says: HeldLine(d)})
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(held) > 0:
|
||||
out = append(out, held...)
|
||||
case h.coverHeld(id) != "":
|
||||
out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Published, Walk: id, For: waited,
|
||||
Bound: coverAskBound.String(), H2: "none: it is asked to leave the merge out past the bound",
|
||||
Says: "held back: " + h.coverHeld(id)})
|
||||
case h.waitsWhy[id] != "":
|
||||
out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Published, Walk: id, For: waited,
|
||||
Bound: "0s", H2: "none: it goes at its turn", Says: "not let go yet: " + h.waitsWhy[id]})
|
||||
case !live && h.lapsedOwed(w):
|
||||
// Its word comes from the stopped deliveries whose merge it answers (novox/hq issue 753): said, never
|
||||
// unheld, while the controller has not taken it, with what holds it.
|
||||
lapsed := h.lapsedOn(w)
|
||||
says := "its word is owed by the stopped deliveries whose merge it answers, each stopped for a member " +
|
||||
"delivered since (novox/hq issue 753)"
|
||||
if len(lapsed) > 0 {
|
||||
says = "its word is given for " + strings.Join(lapsed, ", ") + ", stopped for a member delivered since, " +
|
||||
"whose merge it answers (novox/hq issue 753)"
|
||||
}
|
||||
if why := h.lapsedSaid(w); why != "" {
|
||||
says += "; " + why
|
||||
}
|
||||
out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Published, Walk: id, For: waited,
|
||||
Bound: "0s", H2: "none: it goes at its turn", Says: says})
|
||||
case !live:
|
||||
says := "no delivery that has not ended follows it or is answered by it, so nothing gives it its word: " +
|
||||
"`mesh-delivery.show` for the deliveries of " + w.Repository
|
||||
if ended := h.endedAsNothingToMove(w); len(ended) > 0 {
|
||||
// The nzbgeek case (novox/hq issues 362, 529): its merges' deliveries ended as nothing for a walk to
|
||||
// move, yet the walk moves modules — nothing judged those, so nothing here lets it go.
|
||||
says = "held by nothing: its merges' deliveries ended as nothing-to-move (" + strings.Join(ended, ", ") +
|
||||
"), yet the walk moves " + strings.Join(walkModules(w), ", ") + " — nothing gives it its word"
|
||||
}
|
||||
out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Unheld, Walk: id, For: waited,
|
||||
Bound: "0s", H2: "none: no delivery holds the walk — the operator's", Says: says + h.endedSaid(w)})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Walk != out[j].Walk {
|
||||
return out[i].Walk < out[j].Walk
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// walkModules are the modules a walk's record names, sorted.
|
||||
func walkModules(w Walk) []string {
|
||||
out := make([]string, 0, len(w.Modules))
|
||||
for m := range w.Modules {
|
||||
out = append(out, m)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -140,6 +140,14 @@ func repoName(repository string) string {
|
||||
|
||||
func pullSaid(d *Delivery) string { return fmt.Sprintf("%s#%d", repoName(d.Repository), d.Number) }
|
||||
|
||||
// touchesSaid is the modules the delivery's check says its change touches, for the ask; the gate plans no delivery.
|
||||
func touchesSaid(d *Delivery) string {
|
||||
if d.Check == nil || len(d.Check.Modules) == 0 {
|
||||
return "no module the check named"
|
||||
}
|
||||
return strings.Join(d.Check.Modules, ", ")
|
||||
}
|
||||
|
||||
// composeMergeAsk is the ask for a delivery, or for a group's members together.
|
||||
func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery, heads map[string]string, now time.Time) asks.Ask {
|
||||
var a asks.Ask
|
||||
@@ -155,13 +163,10 @@ func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery,
|
||||
unreviewed = pullSaid(m) + " is " + words
|
||||
}
|
||||
reviewers = append(reviewers, fmt.Sprintf("%s: %s", pullSaid(m), words))
|
||||
plan := "no delivery plan yet"
|
||||
if m.Plan != nil && m.Plan.Summary != "" {
|
||||
plan = m.Plan.Summary
|
||||
}
|
||||
reaches = append(reaches, plan)
|
||||
details = append(details, fmt.Sprintf("%s %q at %s: issues: %s; review: %s; reaches: %s",
|
||||
pullSaid(m), m.Title, shortOf(m.Commit, 12), issuesSaid([]*Delivery{m}), words, plan))
|
||||
touches := touchesSaid(m)
|
||||
reaches = append(reaches, touches)
|
||||
details = append(details, fmt.Sprintf("%s %q at %s: issues: %s; review: %s; touches: %s",
|
||||
pullSaid(m), m.Title, shortOf(m.Commit, 12), issuesSaid([]*Delivery{m}), words, touches))
|
||||
}
|
||||
if group == "" {
|
||||
a.Headline = phone("Merge into main: "+pullSaid(d)+" "+d.Title, asks.HeadlineLength)
|
||||
@@ -174,7 +179,7 @@ func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery,
|
||||
phone(summary, asks.SummaryLength),
|
||||
phone("Issues: "+issuesSaid(members), asks.PointLength),
|
||||
phone("Review: "+reviewers[0], asks.PointLength),
|
||||
phone("Reaches: "+reaches[0], asks.PointLength),
|
||||
phone("Touches: "+reaches[0], asks.PointLength),
|
||||
}, "\n")
|
||||
} else {
|
||||
var names []string
|
||||
@@ -191,7 +196,7 @@ func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery,
|
||||
phone(summary, asks.SummaryLength),
|
||||
phone("Issues: "+issuesSaid(members), asks.PointLength),
|
||||
phone("Review: "+strings.Join(reviewers, "; "), asks.PointLength),
|
||||
phone("Reaches: "+strings.Join(reaches, "; "), asks.PointLength),
|
||||
phone("Touches: "+strings.Join(reaches, "; "), asks.PointLength),
|
||||
}, "\n")
|
||||
}
|
||||
a.Details = phone(strings.Join(details, "\n"), 1500)
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestAMarkedGreenDeliveryIntoMainIsAskedAndNotMergedUntilApproved(t *testing
|
||||
len(a.Options) != 2 || a.Options[0].ID != OptionApprove || a.Options[1].ID != OptionDecline {
|
||||
t.Fatalf("the ask is not the merge ask: %+v", a)
|
||||
}
|
||||
for _, want := range []string{"issue 948", "issue 12", "reviewed by reviewer on shanks", "builds app"} {
|
||||
for _, want := range []string{"issue 948", "issue 12", "reviewed by reviewer on shanks", "Touches: "} {
|
||||
if !strings.Contains(a.Explanation, want) {
|
||||
t.Fatalf("the ask does not say %q: %s", want, a.Explanation)
|
||||
}
|
||||
@@ -72,7 +72,6 @@ func TestAMarkedGreenDeliveryIntoMainIsAskedAndNotMergedUntilApproved(t *testing
|
||||
t.Fatalf("the warrant is not kept: %+v", d.Ready)
|
||||
}
|
||||
w.h.PullMerged(merged("novox/app", 7, readyHead, "m7m7m7m7m7m7"))
|
||||
w.h.WalkMoved(w.ctl.put(aWalk("plan-7", "novox/app", "m7m7m7m7m7m7", true)))
|
||||
w.settleAll()
|
||||
var last Transition
|
||||
for _, tr := range w.delivery(id).Transitions {
|
||||
@@ -373,7 +372,7 @@ func TestIssue1025AnOwedMergedIsSaidAfterARestartAndOnce(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.h.mu.Unlock()
|
||||
again := newWorldOver(t, w.store, w.ctl, w.forge, w.now.Add(time.Minute))
|
||||
again := newWorldOver(t, w.store, w.forge, w.now.Add(time.Minute))
|
||||
again.settleAll()
|
||||
again.later(time.Hour)
|
||||
again.settleAll()
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A failed backup before the send holds the delivery, and a release asks the backups again (novox/hq ADR 0404
|
||||
// clause 4, issue 1068). The walk's backups field is the controller's contract.
|
||||
|
||||
func failedBackup() *WalkBackups {
|
||||
return &WalkBackups{State: "failed", Asked: []WalkBackupAsked{
|
||||
{Machine: "ace", Module: "mail", State: "done"},
|
||||
{Machine: "ace", Module: "pg", State: "failed", Words: "no space left on device"}}}
|
||||
}
|
||||
|
||||
// aDeliveringWalk is a delivery whose walk was let go and runs, nothing sent yet.
|
||||
func aDeliveringWalk(t *testing.T) (*world, string, string) {
|
||||
t.Helper()
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", head)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
w.h.PullMerged(merged("novox/app", 7, head, merge))
|
||||
w.h.WalkMoved(w.ctl.put(aWalk("plan-1", "novox/app", merge, true)))
|
||||
w.settleAll()
|
||||
if s := w.state(id); s != Delivering {
|
||||
t.Fatalf("setup: %s, want delivering", s)
|
||||
}
|
||||
return w, id, "plan-1"
|
||||
}
|
||||
|
||||
func TestABackupFailedTakesADeliveringDeliveryToHeldOnlyWhileNothingWasSent(t *testing.T) {
|
||||
w, id, plan := aDeliveringWalk(t)
|
||||
sent := w.ctl.walk(plan)
|
||||
sent.Backups = failedBackup()
|
||||
sent.Modules["app"].SentAt = &w.now
|
||||
w.h.WalkMoved(w.ctl.put(sent))
|
||||
w.settleAll()
|
||||
if s := w.state(id); s == Held {
|
||||
t.Fatal("held for a failed backup after a module of the plan was sent")
|
||||
}
|
||||
w, id, plan = aDeliveringWalk(t)
|
||||
held := w.ctl.walk(plan)
|
||||
held.Backups = failedBackup()
|
||||
w.h.WalkMoved(w.ctl.put(held))
|
||||
w.settleAll()
|
||||
d := w.delivery(id)
|
||||
if d.State != Held {
|
||||
t.Fatalf("with a failed backup and nothing sent it is %s", d.State)
|
||||
}
|
||||
for _, want := range []string{"ace", "pg", "no space left on device"} {
|
||||
if !strings.Contains(d.HeldWhy, want) {
|
||||
t.Errorf("the held reason %q does not name %q", d.HeldWhy, want)
|
||||
}
|
||||
}
|
||||
// It does not lift by itself: the same facts read again leave it held.
|
||||
w.h.WalkMoved(w.ctl.put(w.ctl.walk(plan)))
|
||||
w.settleAll()
|
||||
if s := w.state(id); s != Held {
|
||||
t.Fatalf("read again it is %s, want it still held", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAReleaseOfADeliveryHeldForAFailedBackupAsksTheControllerAgainAndGoesToDelivering(t *testing.T) {
|
||||
w, id, plan := aDeliveringWalk(t)
|
||||
held := w.ctl.walk(plan)
|
||||
held.Backups = failedBackup()
|
||||
w.h.WalkMoved(w.ctl.put(held))
|
||||
w.settleAll()
|
||||
if s := w.state(id); s != Held {
|
||||
t.Fatalf("setup: %s, want held", s)
|
||||
}
|
||||
lettingGo := len(w.ctl.delivers)
|
||||
if _, err := w.h.Release(id, "freed some space", "jochen"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := w.ctl.backupsAgain; len(got) != 1 || got[0] != plan {
|
||||
t.Fatalf("the controller was asked to take the backups again %v, want once for %s", got, plan)
|
||||
}
|
||||
if len(w.ctl.delivers) != lettingGo {
|
||||
t.Errorf("a started walk was let go again: %v", w.ctl.delivers)
|
||||
}
|
||||
if s := w.state(id); s != Delivering {
|
||||
t.Fatalf("released, it is %s", s)
|
||||
}
|
||||
// The record the controller cleared is read: it stays delivering.
|
||||
w.h.WalkMoved(w.ctl.walk(plan))
|
||||
w.settleAll()
|
||||
if s := w.state(id); s != Delivering {
|
||||
t.Fatalf("with the backups cleared it is %s", s)
|
||||
}
|
||||
// Another failure, in a later record, holds it again.
|
||||
again := w.ctl.walk(plan)
|
||||
again.Backups = failedBackup()
|
||||
w.h.WalkMoved(w.ctl.put(again))
|
||||
w.settleAll()
|
||||
if s := w.state(id); s != Held {
|
||||
t.Fatalf("a failed backup in a later record left it %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARecordThatStillHoldsTheFailureAfterAReleaseDoesNotHoldItAgain(t *testing.T) {
|
||||
w, id, plan := aDeliveringWalk(t)
|
||||
held := w.ctl.walk(plan)
|
||||
held.Backups = failedBackup()
|
||||
stale := w.ctl.put(held)
|
||||
w.h.WalkMoved(stale)
|
||||
w.settleAll()
|
||||
if _, err := w.h.Release(id, "freed some space", "jochen"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.h.WalkMoved(stale) // the record as it was before the controller cleared it
|
||||
w.settleAll()
|
||||
if s := w.state(id); s != Delivering {
|
||||
t.Fatalf("a record from before the release held it again: %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADeliveryHeldForAFailedBackupIsNotLetGoByItsWalkAlone(t *testing.T) {
|
||||
// The controller's own path starts a walk with nobody's word: the held-to-delivering row for that must not
|
||||
// undo the backup hold.
|
||||
d := &Delivery{ID: "novox/app@c0", State: Held, MergedAs: "aaaa", Walk: &WalkSeen{ID: "plan-1", State: walkBuilding, Revision: 3,
|
||||
BackupsFailed: []WalkBackupFailure{{Machine: "ace", Module: "pg", Words: "no space left on device"}}}}
|
||||
if _, err := Apply(d, EvGo, false, Facts{}); err == nil {
|
||||
t.Fatal("a walk started on the controller's own path lifted the backup hold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAReleaseWhoseBackupsTheControllerRefusedToAskAgainLeavesTheDeliveryHeld(t *testing.T) {
|
||||
w, id, plan := aDeliveringWalk(t)
|
||||
held := w.ctl.walk(plan)
|
||||
held.Backups = failedBackup()
|
||||
w.h.WalkMoved(w.ctl.put(held))
|
||||
w.settleAll()
|
||||
w.ctl.refuseBackupsAgain = true
|
||||
_, err := w.h.Release(id, "freed some space", "jochen")
|
||||
if err == nil || !strings.Contains(err.Error(), "held again") {
|
||||
t.Fatalf("the release of a refused word answered %v, want an error saying it is held again", err)
|
||||
}
|
||||
w.settleAll()
|
||||
d := w.delivery(id)
|
||||
if d.State != Held || d.BackupsAgainAfter != 0 || !strings.Contains(d.HeldWhy, "no space left on device") {
|
||||
t.Fatalf("after the refusal it is %s (again-after %d, why %q), want held for the backup", d.State, d.BackupsAgainAfter, d.HeldWhy)
|
||||
}
|
||||
// The controller takes the word the next time: the delivery goes on.
|
||||
w.ctl.refuseBackupsAgain = false
|
||||
if _, err := w.h.Release(id, "freed some space", "jochen"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := w.state(id); s != Delivering {
|
||||
t.Fatalf("released again, it is %s", s)
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,6 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -85,8 +83,8 @@ func newTestBus(t *testing.T) *testBus {
|
||||
}
|
||||
b.kvs[name] = kv
|
||||
}
|
||||
// The buckets of other modules the manifest reads (`<module>.<name>` is the bucket `<module>_<name>`): the gate reads
|
||||
// who has the seat there, and today's state to carry (seat.go).
|
||||
// The buckets of other modules the manifest reads (`<module>.<name>` is the bucket `<module>_<name>`): the teller's answers
|
||||
// and the checker's verdicts.
|
||||
for _, name := range m.Reads {
|
||||
kv, err := js.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: strings.Replace(name, ".", "_", 1), History: 10})
|
||||
if err != nil {
|
||||
@@ -94,13 +92,9 @@ func newTestBus(t *testing.T) *testBus {
|
||||
}
|
||||
b.kvs[name] = kv
|
||||
}
|
||||
// The node-engine has said today's module removed on the one machine: the gate may hold the seat (ADR 0450 §6, issue 1369).
|
||||
if _, err := b.kvs["node-engine.have"].Put(context.Background(), "test.mesh-delivery", []byte(`{"v":1,"phase":"removed","health":"unknown"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The streams the events are on (mesh.mod.<module>.event.<name>, mesh.seat.<seat>.event.<name>).
|
||||
if _, err := js.CreateStream(context.Background(), jetstream.StreamConfig{Name: "EVENTS",
|
||||
Subjects: []string{"mesh.mod.gitea.event.>", "mesh.seat." + ControllerSeat + ".event.>", "mesh.seat." + asks.Seat + ".event.>",
|
||||
Subjects: []string{"mesh.mod.gitea.event.>", "mesh.seat." + asks.Seat + ".event.>",
|
||||
"mesh.mod." + sdk.ForgeBridgeModule + ".event.>"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -208,15 +202,29 @@ func (r *runtime) read(out io.Reader) {
|
||||
}
|
||||
}
|
||||
|
||||
// untilHolding waits for the gate to hold the seat: it holds it when it reads the controller's walks once, the first thing it
|
||||
// does after (readOnHolding), and it takes no event before.
|
||||
func (r *runtime) untilHolding() {
|
||||
// untilListening waits for the gate to have read its kept state back and subscribed to every event it follows: it takes no
|
||||
// event before (loadKept), so a subscription is its word that it is ready.
|
||||
func (r *runtime) untilListening() {
|
||||
r.t.Helper()
|
||||
r.until("the gate to hold the seat", 20*time.Second, func() bool {
|
||||
return len(r.asksOf("seat:"+ControllerSeat+".delivery-walks")) > 0
|
||||
r.until("the gate to listen to every event it follows", 20*time.Second, func() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return len(r.subs) >= len(followed)
|
||||
})
|
||||
}
|
||||
|
||||
// noControllerAsk fails the test when the gate asked the controller anything: it asks it nothing (ADR 0539 §17, R6-C3).
|
||||
func (r *runtime) noControllerAsk() {
|
||||
r.t.Helper()
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, a := range r.asked {
|
||||
if strings.HasPrefix(a.Key, "seat:mesh-controller.") {
|
||||
r.t.Fatalf("the gate asked the controller %s: it asks it nothing", a.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *runtime) ping() {
|
||||
select {
|
||||
case r.notify <- struct{}{}:
|
||||
@@ -334,11 +342,8 @@ func (r *runtime) answer(m rpcMessage) {
|
||||
pulls := append([]OpenPull{}, r.openPulls...)
|
||||
r.mu.Unlock()
|
||||
switch a.Key {
|
||||
case "seat:" + ControllerSeat + ".delivery-walks":
|
||||
return toolAnswer(map[string]any{"held": true, "walks": []any{}}), nil
|
||||
case "seat:" + ControllerSeat + ".delivery-plan":
|
||||
return toolAnswer(map[string]any{"plan": DeliveryPlan{Repository: "novox/app", Moved: []string{"app"},
|
||||
Summary: "builds app, sent to the node"}}), nil
|
||||
case "":
|
||||
return nil, errors.New("an ask with no key")
|
||||
case "gitea.gitea_open_pulls":
|
||||
return toolAnswer(map[string]any{"pulls": pulls}), nil
|
||||
case "gitea.gitea_commit_statuses":
|
||||
@@ -355,6 +360,10 @@ func (r *runtime) answer(m rpcMessage) {
|
||||
"gitea.gitea_add_comment":
|
||||
return toolAnswer(map[string]any{}), nil
|
||||
}
|
||||
if strings.HasPrefix(a.Key, "seat:mesh-controller.") {
|
||||
// The fake controller of this test fails on any call: the gate has no business with it.
|
||||
return nil, fmt.Errorf("the controller is not to be called by the gate: %s", a.Key)
|
||||
}
|
||||
return nil, fmt.Errorf("the runtime answers no ask %s in this test", a.Key)
|
||||
}
|
||||
kv := r.b.kvs[p.State]
|
||||
@@ -495,12 +504,7 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB
|
||||
defer cancel()
|
||||
rt.deliverEvents(evctx)
|
||||
// The module has started when it hears what it follows.
|
||||
rt.until("the module to subscribe to the events it follows", 20*time.Second, func() bool {
|
||||
rt.mu.Lock()
|
||||
defer rt.mu.Unlock()
|
||||
return len(rt.subs) >= len(followed)
|
||||
})
|
||||
rt.untilHolding()
|
||||
rt.untilListening()
|
||||
for _, k := range followed {
|
||||
if strings.Contains(k, "labeled") || strings.Contains(k, "commented") {
|
||||
t.Fatalf("the module follows %s: a label or a comment starts nothing here (ADR 0421 §4)", k)
|
||||
@@ -580,6 +584,8 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB
|
||||
// The bridge says it merged; the mesh's source then holds the merge, in one write.
|
||||
b.forgeSays(t, sdk.Forge{Kind: sdk.ForgeMerged, Repository: "novox/app", Number: 7, Head: head, Merge: strings.Repeat("b", 40),
|
||||
Root: "r", Trees: map[string]string{"app": "t"}})
|
||||
// The forge's own word that the pull request merged follows the bridge's (its webhook): the delivery ends merged.
|
||||
b.publish(t, "mesh.mod.gitea.event.pull.merged", merged("novox/app", 7, head, strings.Repeat("b", 40)))
|
||||
rt.until("source.main to hold the merge", eventWithin, func() bool {
|
||||
e, err := b.kvs["source"].Get(ctx, sdk.SourceMainKey)
|
||||
if err != nil {
|
||||
@@ -588,6 +594,13 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB
|
||||
src, err := sdk.DecodeSource(e.Value())
|
||||
return err == nil && src.Group == "fix/x" && src.Heads["novox/app"] == strings.Repeat("b", 40)
|
||||
})
|
||||
// The delivery ends merged (ADR 0539 §17), and the gate asked the controller nothing (R6-C3).
|
||||
rt.until("the delivery to be merged", eventWithin, func() bool {
|
||||
e, err := b.kvs["deliveries"].Get(ctx, kvKey(IDOf("novox/app", head)))
|
||||
var d Delivery
|
||||
return err == nil && json.Unmarshal(e.Value(), &d) == nil && d.State == Merged
|
||||
})
|
||||
rt.noControllerAsk()
|
||||
// Quiet follows: nothing asks the forge or the controller again with no event to say so.
|
||||
quiet := len(rt.asked)
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
@@ -690,12 +703,7 @@ func TestAGroupOfTwoWithGreenChecksGetsOneAskAndOnItsApproveBothMergeOneAfterThe
|
||||
evctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
rt.deliverEvents(evctx)
|
||||
rt.until("the module to subscribe to the events it follows", 20*time.Second, func() bool {
|
||||
rt.mu.Lock()
|
||||
defer rt.mu.Unlock()
|
||||
return len(rt.subs) >= len(followed)
|
||||
})
|
||||
rt.untilHolding()
|
||||
rt.untilListening()
|
||||
const ctlHead, catHead = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2"
|
||||
for _, p := range []PullEvent{pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"),
|
||||
pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json")} {
|
||||
@@ -706,9 +714,6 @@ func TestAGroupOfTwoWithGreenChecksGetsOneAskAndOnItsApproveBothMergeOneAfterThe
|
||||
// Both members pass at the checker, whose one verdict covers both heads: the group is asked once, no composed check is asked
|
||||
// of the controller (issue 1318).
|
||||
b.checkPasses(t, rt, "feat/x", "a1a1a1a1a1a1", judgedPull{"novox/mesh-controller", 1, ctlHead}, judgedPull{"novox/mesh-catalog", 2, catHead})
|
||||
if n := len(rt.asksOf("seat:" + ControllerSeat + ".delivery-order")); n != 0 {
|
||||
t.Fatalf("the controller's delivery-order was asked %d times: no order is asked for", n)
|
||||
}
|
||||
var key string
|
||||
rt.until("the operator to be asked once for the group", 10*time.Second, func() bool { key = onlyAsk(b); return key != "" })
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
@@ -758,163 +763,25 @@ func TestAGroupOfTwoWithGreenChecksGetsOneAskAndOnItsApproveBothMergeOneAfterThe
|
||||
if keys, _ := b.kvs["asks"].Keys(ctx); len(keys) != 1 {
|
||||
t.Fatalf("a second ask was made: %v", keys)
|
||||
}
|
||||
}
|
||||
|
||||
// **The acceptance of a failed backup before the send, on the mesh-delivery side** (novox/hq ADR 0404 clause 4, design 47
|
||||
// milestone 4, issue 1068): a merged delivery whose walk runs is held when the walk's record, as the controller's
|
||||
// `plan-moved` carries it, names a backup that failed and nothing was sent; the hold names the machine, the module and the
|
||||
// backup's words, and nothing asks the controller to let it go. One test function, one bus. The walk's backups field is the controller's
|
||||
// contract (issue 1065).
|
||||
func TestAPlanMovedCarryingAFailedBackupHoldsTheDeliveryWithTheBackupsWordsOverARealBus(t *testing.T) {
|
||||
b := newTestBus(t)
|
||||
rt := b.start(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
rt.deliverEvents(ctx)
|
||||
rt.until("the module to subscribe to the events it follows", 20*time.Second, func() bool {
|
||||
rt.mu.Lock()
|
||||
defer rt.mu.Unlock()
|
||||
return len(rt.subs) >= len(followed)
|
||||
// The forge's own word that each pull request merged follows the bridge's (its webhook), after the second was handed (the
|
||||
// checker judges the members still open by the trunk the first merge moved, which this test does not play): both members
|
||||
// end merged, the group with them, and the controller was never called (R6-C3).
|
||||
b.publish(t, "mesh.mod.gitea.event.pull.merged", merged(one.Repository, one.Number, one.Head.Head, strings.Repeat("a", 40)))
|
||||
b.publish(t, "mesh.mod.gitea.event.pull.merged", merged(two.Repository, two.Number, two.Head.Head, strings.Repeat("b", 40)))
|
||||
for _, id := range []string{IDOf("novox/mesh-controller", ctlHead), IDOf("novox/mesh-catalog", catHead)} {
|
||||
id := id
|
||||
rt.until(id+" to be merged", eventWithin, func() bool {
|
||||
e, err := b.kvs["deliveries"].Get(ctx, kvKey(id))
|
||||
var d Delivery
|
||||
return err == nil && json.Unmarshal(e.Value(), &d) == nil && d.State == Merged
|
||||
})
|
||||
}
|
||||
rt.until("the group to read merged", eventWithin, func() bool {
|
||||
e, err := b.kvs["groups"].Get(ctx, kvKey("feat/x"))
|
||||
var g Group
|
||||
return err == nil && json.Unmarshal(e.Value(), &g) == nil && g.Said == "merged"
|
||||
})
|
||||
rt.untilHolding()
|
||||
const head, merge = "3333333333333333333333333333333333333333", "4444444444444444444444444444444444444444"
|
||||
announced := pr("novox/app", 9, head, "fix/y", "src/a.go")
|
||||
announced.BaseSHA = "a1a1a1a1a1a1"
|
||||
deliveryOf := func() Delivery {
|
||||
e, err := b.kvs["deliveries"].Get(context.Background(), kvKey(IDOf("novox/app", head)))
|
||||
var d Delivery
|
||||
if err == nil {
|
||||
_ = json.Unmarshal(e.Value(), &d)
|
||||
}
|
||||
return d
|
||||
}
|
||||
// Each event is published after the module has taken the one before it: they are on two subjects, and the runner
|
||||
// may hand over a later one first, which the table would rightly judge on its own (merged before checked).
|
||||
b.publish(t, "mesh.mod.gitea.event.pull.updated", announced)
|
||||
rt.until("the pull request to be taken", 10*time.Second, func() bool { return deliveryOf().State != "" })
|
||||
b.checkPasses(t, rt, "fix/y", "a1a1a1a1a1a1", judgedPull{"novox/app", 9, head})
|
||||
rt.until("the verdict to be taken", 10*time.Second, func() bool { d := deliveryOf(); return d.State != "" && d.State != Proposed })
|
||||
b.publish(t, "mesh.mod.gitea.event.pull.merged", merged("novox/app", 9, head, merge))
|
||||
rt.until("the merge to be taken", 10*time.Second, func() bool { return deliveryOf().MergedAs != "" })
|
||||
|
||||
// Its walk runs, let go: the record names no failed backup yet.
|
||||
now := time.Now()
|
||||
walk := aWalk("plan-1", "novox/app", merge, true)
|
||||
walk.Created, walk.State, walk.Revision = now, walkRolling, 1
|
||||
walk.Delivery.Go, walk.Delivery.By = &now, "mesh-delivery"
|
||||
b.publish(t, "mesh.seat."+ControllerSeat+".event.plan-moved", walk)
|
||||
rt.until("the delivery to be delivering", 10*time.Second, func() bool { return deliveryOf().State == Delivering })
|
||||
|
||||
// A backup failed before the send: the delivery is held with the backup's words.
|
||||
walk.Revision, walk.Backups = 2, &WalkBackups{State: "failed", Asked: []WalkBackupAsked{{Machine: "ace", Module: "pg", State: "failed", Words: "no space left on device"}}}
|
||||
b.publish(t, "mesh.seat."+ControllerSeat+".event.plan-moved", walk)
|
||||
rt.until("the delivery to be held for the failed backup", 10*time.Second, func() bool { return deliveryOf().State == Held })
|
||||
d := deliveryOf()
|
||||
for _, want := range []string{"ace", "pg", "no space left on device"} {
|
||||
if !strings.Contains(d.HeldWhy, want) {
|
||||
t.Fatalf("the held reason %q does not name %q", d.HeldWhy, want)
|
||||
}
|
||||
}
|
||||
// It stays held, and the controller is asked nothing to let it go.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if s := deliveryOf().State; s != Held {
|
||||
t.Fatalf("the hold lifted by itself: %s", s)
|
||||
}
|
||||
if n := len(rt.asksOf("seat:"+ControllerSeat+".deliver")) + len(rt.asksOf("seat:"+ControllerSeat+".delivery-backups-again")); n != 0 {
|
||||
t.Fatalf("the controller was asked %d times to let the held walk go or take its backups again", n)
|
||||
}
|
||||
}
|
||||
|
||||
// **AC3 of issue 1347 (ADR 0437 §7's check): the gate carries a live mesh's open deliveries and groups, once.** The buckets of
|
||||
// today's module are filled from testdata/live-capture-2026-10-20.json (its source is said in the file), the gate starts with
|
||||
// today's module on no machine, and every delivery and group arrives in its own buckets unchanged but for the ask an Approve
|
||||
// waited on; a second start copies nothing.
|
||||
func TestTheGateCarriesALiveCapturesDeliveriesAndGroupsOnceOverARealBus(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", "live-capture-2026-10-20.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var capture struct {
|
||||
Deliveries []json.RawMessage `json:"deliveries"`
|
||||
Groups []json.RawMessage `json:"groups"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &capture); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := newTestBus(t)
|
||||
ctx := context.Background()
|
||||
want := map[string]Delivery{}
|
||||
for _, one := range capture.Deliveries {
|
||||
var d Delivery
|
||||
if err := json.Unmarshal(one, &d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := b.kvs["mesh-delivery.deliveries"].Put(ctx, kvKey(d.ID), one); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Ready != nil { // what the carry clears: the ask nobody answered
|
||||
d.Ready.AskID, d.Ready.Ask, d.Ready.Heads = "", nil, nil
|
||||
}
|
||||
want[kvKey(d.ID)] = d
|
||||
}
|
||||
for _, one := range capture.Groups {
|
||||
var g Group
|
||||
if err := json.Unmarshal(one, &g); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := b.kvs["mesh-delivery.groups"].Put(ctx, kvKey(g.ID), one); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
rt := b.start(t)
|
||||
rt.untilHolding()
|
||||
for key, d := range want {
|
||||
e, err := b.kvs["deliveries"].Get(ctx, key)
|
||||
if err != nil {
|
||||
t.Fatalf("the delivery %s did not arrive: %v", key, err)
|
||||
}
|
||||
var got Delivery
|
||||
if err := json.Unmarshal(e.Value(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// What is compared is what the carry copies and the gate never rewrites: who the delivery is. By the time this reads, the
|
||||
// gate holds the seat and acts on the delivery (it asks a check, owes a note), which changes its check ask, its owed
|
||||
// effects and its transitions, so a whole-value comparison depends on timing. The carry's own exactness is shown without a
|
||||
// bus by TestTheCarryCopiesWhatItDoesNotClearByteForByte and TestTheGateCarriesTodaysStateOnceWhenItFirstHoldsTheSeat.
|
||||
if got.ID != d.ID || got.Repository != d.Repository || got.Commit != d.Commit || got.Number != d.Number || got.Base != d.Base ||
|
||||
got.Group != d.Group || !got.Created.Equal(d.Created) {
|
||||
t.Fatalf("the delivery %s changed in the carry:\n got %s\nwant %s", key, mustJSON(got), mustJSON(d))
|
||||
}
|
||||
}
|
||||
for _, one := range capture.Groups {
|
||||
var g Group
|
||||
_ = json.Unmarshal(one, &g)
|
||||
e, err := b.kvs["groups"].Get(ctx, kvKey(g.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("the group %s did not arrive: %v", g.ID, err)
|
||||
}
|
||||
// The carry copies the group byte for byte (TestTheCarryCopiesWhatItDoesNotClearByteForByte); by the time this reads, the gate
|
||||
// holds the seat and acts, and writes a group's said state as it does, so what is compared is what only the carry could change.
|
||||
got := mustDecode[Group](t, e.Value())
|
||||
if got.ID != g.ID || !slices.Equal(got.Members, g.Members) || !got.Created.Equal(g.Created) {
|
||||
t.Fatalf("the group %s changed in the carry: %+v", g.ID, got)
|
||||
}
|
||||
}
|
||||
if _, err := b.kvs["deliveries"].Get(ctx, carriedKey); err != nil {
|
||||
t.Fatalf("the mark carried was not written: %v", err)
|
||||
}
|
||||
// A second start copies nothing: a delivery today's module gains meanwhile stays where it is.
|
||||
_ = rt.in.Close()
|
||||
_ = rt.cmd.Wait()
|
||||
late := mustJSON(&Delivery{ID: "novox/app@999999999999", Repository: "novox/app", Commit: "999999999999", Number: 99, State: Proposed})
|
||||
if _, err := b.kvs["mesh-delivery.deliveries"].Put(ctx, kvKey("novox/app@999999999999"), late); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again := b.start(t)
|
||||
again.untilHolding()
|
||||
if _, err := b.kvs["deliveries"].Get(ctx, kvKey("novox/app@999999999999")); !errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
t.Fatalf("a second start copied a key (%v)", err)
|
||||
}
|
||||
rt.noControllerAsk()
|
||||
}
|
||||
|
||||
func mustDecode[T any](t *testing.T, raw []byte) T {
|
||||
|
||||
@@ -12,11 +12,10 @@ import (
|
||||
// **The gate is the only claimant of the seat `mesh-delivery` in the catalogue** (novox/hq ADR 0437 §1, ADR 0449 §4, ADR 0454,
|
||||
// issue 1351; how it is checked). Today's module `modules/mesh-delivery` was removed by a pull request of its own after the
|
||||
// handover, so the claimants are exactly one. And no manifest but the gate's and mesh-issues' names the old module in what it
|
||||
// reads or hears, but through the seat's name (`mesh-delivery` as a seat or a repository, `seat:mesh-delivery.<verb>`) and the
|
||||
// names below: the three buckets the gate carries over (ADR 0450 §6). The event `mesh-delivery.transition` is no longer heard by anyone (issue 1406).
|
||||
var oldModuleNames = map[string][]string{
|
||||
"mesh-delivery-gate": {"mesh-delivery.deliveries", "mesh-delivery.groups", "mesh-delivery.pulls"},
|
||||
}
|
||||
// reads or hears, but through the seat's name (`mesh-delivery` as a seat or a repository, `seat:mesh-delivery.<verb>`). The
|
||||
// three buckets the gate once carried over (ADR 0450 §6) went with the carry (ADR 0539 §17), and the event
|
||||
// `mesh-delivery.transition` is heard by no one (issue 1406).
|
||||
var oldModuleNames = map[string][]string{}
|
||||
|
||||
// seatClaimants are the modules that claim the seat: the gate alone.
|
||||
var seatClaimants = []string{"mesh-delivery-gate"}
|
||||
|
||||
@@ -15,6 +15,9 @@ const (
|
||||
ctxRepo = "mesh/repo-check"
|
||||
)
|
||||
|
||||
// shimCheckedKey is the key a test hears a verdict under: said as the checker's (see Checked).
|
||||
const shimCheckedKey = "mesh-controller.checked"
|
||||
|
||||
// CheckedEvent is a verdict as these tests state it. The gate follows no verdict of the controller any more (issue 1318,
|
||||
// ADR 0524 §5): the shim below says it the way the mesh says it now, as the head's mesh/check and the checker's verdict at
|
||||
// the heads, so the table, the Approve and the merges are exercised on the one path they have.
|
||||
@@ -38,7 +41,6 @@ type CheckedEvent struct {
|
||||
Verdict string `json:"verdict"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
Plan *DeliveryPlan
|
||||
Group string
|
||||
Members []struct {
|
||||
Owner string `json:"owner"`
|
||||
@@ -85,9 +87,6 @@ func (h *Holder) Checked(c CheckedEvent) {
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
if c.Plan != nil {
|
||||
d.Plan = c.Plan
|
||||
}
|
||||
words := map[string]string{"success": "pass", "warning": "pass", "failure": "failed: " + c.Summary, "error": "error"}[state]
|
||||
line := words + " " + shortOf(c.Commit, 12)
|
||||
if c.Trunk != "" {
|
||||
|
||||
@@ -69,9 +69,11 @@ type ChecksOf struct {
|
||||
Since string `json:"since"`
|
||||
Waits string `json:"waits,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
// Class, Opened and MergedAt are what the issue tracker measures a fix's time by (novox/hq ADR 0313): the
|
||||
// class of what it moves, when its pull request was first heard, and when it merged.
|
||||
Class string `json:"class,omitempty"`
|
||||
// MergedAs is the commit its merge landed as on the trunk, once merged: what the delivery view's `delivered` event is
|
||||
// matched to a fix by (ADR 0539 clause 17).
|
||||
MergedAs string `json:"merged_as,omitempty"`
|
||||
// Opened and MergedAt are what the issue tracker measures a fix's time by (novox/hq ADR 0313): when its pull request
|
||||
// was first heard, and when it merged.
|
||||
Opened string `json:"opened,omitempty"`
|
||||
MergedAt string `json:"merged_at,omitempty"`
|
||||
}
|
||||
@@ -113,7 +115,7 @@ func (h *Holder) Checks(repository string, number int, commit string) (*ChecksAn
|
||||
}
|
||||
if d != nil {
|
||||
a.Delivery = &ChecksOf{ID: d.ID, State: d.State, Since: d.Since.UTC().Format(time.RFC3339), Waits: d.waits,
|
||||
Group: d.Group, Class: fixClass(&d.Delivery)}
|
||||
Group: d.Group, MergedAs: d.MergedAs}
|
||||
if !d.opened.IsZero() {
|
||||
a.Delivery.Opened = d.opened.UTC().Format(time.RFC3339)
|
||||
}
|
||||
@@ -198,8 +200,8 @@ func (h *Holder) deliveryOf(repository string, number int, commit string) *check
|
||||
|
||||
// newerHead is whether d is a pull request's head to read before e: one not superseded first, then the newest.
|
||||
func newerHead(d, e *Delivery) bool {
|
||||
if (d.State == Superseded) != (e.State == Superseded) {
|
||||
return e.State == Superseded
|
||||
if (d.State == Closed) != (e.State == Closed) {
|
||||
return e.State == Closed
|
||||
}
|
||||
return d.Created.After(e.Created)
|
||||
}
|
||||
|
||||
@@ -1,587 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// novox/hq issues 319 and 316: a held delivery whose change already runs, or that builds nothing, is not a
|
||||
// question for a person. A merge that moves nothing is delivered at once; a merge whose every module a later
|
||||
// merge on the same trunk delivered is covered — never stopped, never an error on its pull request; and a
|
||||
// group member held for a composed check that had not answered goes on once it passes.
|
||||
|
||||
var t0 = time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// mergedAt is the forge's `pull.merged` at a moment.
|
||||
func mergedAt(p PullEvent, merge string, at time.Time) PullEvent {
|
||||
p.MergeCommit = merge
|
||||
p.MergedAt = at.UTC().Format(time.RFC3339)
|
||||
return p
|
||||
}
|
||||
|
||||
// verdictMoving is a passing verdict whose plan moves these modules — or builds nothing, given none.
|
||||
func verdictMoving(repo string, number int, sha string, modules ...string) CheckedEvent {
|
||||
c := verdict(repo, number, sha, "pass")
|
||||
c.Plan = planMoving(repo, modules...)
|
||||
return c
|
||||
}
|
||||
|
||||
func planMoving(repo string, modules ...string) *DeliveryPlan {
|
||||
if len(modules) == 0 {
|
||||
return &DeliveryPlan{Repository: repo, Base: "main", Summary: "builds nothing: the change touches no module of the mesh's graph"}
|
||||
}
|
||||
return &DeliveryPlan{Repository: repo, Base: "main", Moved: modules, Summary: "builds " + strings.Join(modules, ", ") + " → anchor"}
|
||||
}
|
||||
|
||||
// deliverLater is a later merge into a trunk, walked: its walk opened for its merge commit, let go, and left
|
||||
// in the state given, every module of it sent.
|
||||
func deliverLater(w *world, repo string, number int, base, headSHA, merge string, at time.Time, state string,
|
||||
modules ...string) string {
|
||||
w.t.Helper()
|
||||
p := pr(repo, number, headSHA, fmt.Sprintf("feat/later-%d", number))
|
||||
p.Base = base
|
||||
w.h.PullUpdated(p)
|
||||
w.h.Checked(verdictMoving(repo, number, headSHA, modules...))
|
||||
w.h.PullMerged(mergedAt(p, merge, at))
|
||||
walk := aWalk("plan-"+merge[:6], repo, merge, true)
|
||||
walk.Branch = base
|
||||
walk.Modules = map[string]*WalkModule{}
|
||||
for _, m := range modules {
|
||||
walk.Modules[m] = &WalkModule{}
|
||||
}
|
||||
w.h.WalkMoved(w.ctl.put(walk))
|
||||
w.settleAll()
|
||||
walk = w.ctl.walk(walk.ID)
|
||||
sent := at.Add(5 * time.Minute)
|
||||
for _, m := range modules {
|
||||
walk.Modules[m] = &WalkModule{State: "built", SentAt: &sent}
|
||||
}
|
||||
walk.State = state
|
||||
w.h.WalkMoved(w.ctl.put(walk))
|
||||
w.settleAll()
|
||||
return IDOf(repo, headSHA)
|
||||
}
|
||||
|
||||
func statusOn(w *world, repo, commit string) string {
|
||||
w.forge.mu.Lock()
|
||||
defer w.forge.mu.Unlock()
|
||||
return w.forge.statuses[repo+"@"+commit+" mesh/delivery"]
|
||||
}
|
||||
|
||||
// **A merge that moves nothing is delivered at once** (issue 319): there is nothing for a walk to move and
|
||||
// nothing for a person to decide — whether its check passed, failed or never answered, and whether its plan
|
||||
// builds nothing at all or only a new module the controller builds and sends nowhere (issue 300).
|
||||
func TestAMergeThatMovesNothingIsDeliveredAtOnce(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
verdict string // "" is a merge before any verdict
|
||||
plan *DeliveryPlan
|
||||
}{
|
||||
{"builds nothing, merged before its check answered", "", planMoving("novox/hq")},
|
||||
{"builds nothing, merged on a failed check", "fail", planMoving("novox/hq")},
|
||||
{"builds nothing, merged on a passing check", "pass", planMoving("novox/hq")},
|
||||
{"a new module, sent nowhere", "pass", &DeliveryPlan{Repository: "novox/hq", New: []string{"modules/zfs"},
|
||||
Summary: "builds new: modules/zfs, sent nowhere; no bus step"}},
|
||||
{"a new module, sent nowhere, merged before its check answered", "", &DeliveryPlan{Repository: "novox/hq",
|
||||
New: []string{"modules/zfs"}, Summary: "builds new: modules/zfs, sent nowhere; no bus step"}},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.ctl.planned = c.plan
|
||||
p := pr("novox/hq", 7, head, "docs/x", "02-DECISIONS/x.md")
|
||||
w.h.PullUpdated(p)
|
||||
if c.verdict != "" {
|
||||
v := verdict("novox/hq", 7, head, c.verdict)
|
||||
v.Plan = c.plan
|
||||
w.h.Checked(v)
|
||||
}
|
||||
w.later(31 * time.Minute)
|
||||
w.h.PullMerged(mergedAt(p, merge, t0.Add(30*time.Minute)))
|
||||
w.settleAll()
|
||||
// Past the bound a merge waits for its walk: nothing holds it then either.
|
||||
w.later(15 * time.Minute)
|
||||
if err := w.h.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.settleAll()
|
||||
d := w.delivery(IDOf("novox/hq", head))
|
||||
if d.State != Delivered {
|
||||
t.Fatalf("a merge whose plan is %q is %s (%s), not delivered", c.plan.Summary, d.State, d.HeldWhy)
|
||||
}
|
||||
if s := statusOn(w, "novox/hq", head); !strings.HasPrefix(s, "success") {
|
||||
t.Fatalf("its pull request's mesh/delivery says %q", s)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// **A held delivery whose every module a later merge on its trunk delivered is covered** (issue 319): the
|
||||
// later merge commit contains it, its walk is done, and it sent each module this one moves. Anything short
|
||||
// of that — a module not carried, an earlier merge, another trunk, a walk that did not finish — leaves it held
|
||||
// for a person, as before.
|
||||
func TestAMergeALaterDeliveredWalkCarriedIsCovered(t *testing.T) {
|
||||
const ctl = "novox/mesh-controller"
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
// The later merge: its trunk, minutes after t0 it merged (this one merged at 30), its walk's end, and
|
||||
// what it sent.
|
||||
base string
|
||||
at int
|
||||
state string
|
||||
modules []string
|
||||
// Whether this one was held for a person before the later merge was delivered.
|
||||
heldFirst bool
|
||||
// What the forge says of the later merge containing this one: yes, no, or away until asked again.
|
||||
forge string
|
||||
want State
|
||||
}{
|
||||
{"carried every module, delivered after this one was held", "main", 35, walkDone,
|
||||
[]string{"build-agent", "mesh-controller"}, true, "yes", Covered},
|
||||
{"carried every module, delivered before this one was due to be held", "main", 35, walkDone,
|
||||
[]string{"build-agent", "mesh-controller", "route-proxy"}, false, "yes", Covered},
|
||||
{"carried one of its two modules", "main", 35, walkDone, []string{"mesh-controller"}, true, "yes", Held},
|
||||
{"merged before this one", "main", 25, walkDone, []string{"build-agent", "mesh-controller"}, true, "yes", Held},
|
||||
{"merged into another branch", "release", 35, walkDone, []string{"build-agent", "mesh-controller"}, true, "yes", Held},
|
||||
{"its walk failed", "main", 35, walkFailed, []string{"build-agent", "mesh-controller"}, true, "yes", Held},
|
||||
{"the forge says the later merge does not contain it", "main", 35, walkDone,
|
||||
[]string{"build-agent", "mesh-controller"}, true, "no", Held},
|
||||
{"the forge could not say at first, then said it does", "main", 35, walkDone,
|
||||
[]string{"build-agent", "mesh-controller"}, true, "away", Covered},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
pair := merge + " in nnnnnnnnnnnn8888"
|
||||
switch c.forge {
|
||||
case "yes", "away":
|
||||
w.forge.contains[pair] = true
|
||||
case "no":
|
||||
w.forge.contains[pair] = false
|
||||
}
|
||||
w.forge.containsDown = c.forge == "away"
|
||||
p := pr(ctl, 1, head, "feat/one", "cmd/a.go")
|
||||
w.h.PullUpdated(p)
|
||||
w.h.Checked(verdictMoving(ctl, 1, head, "mesh-controller", "build-agent"))
|
||||
w.later(31 * time.Minute)
|
||||
w.h.PullMerged(mergedAt(p, merge, t0.Add(30*time.Minute)))
|
||||
w.settleAll()
|
||||
if c.heldFirst {
|
||||
w.later(15 * time.Minute)
|
||||
if err := w.h.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.settleAll()
|
||||
if s := w.state(IDOf(ctl, head)); s != Held {
|
||||
t.Fatalf("a merge no walk opened for is %s before the later one", s)
|
||||
}
|
||||
}
|
||||
later := deliverLater(w, ctl, 2, c.base, "bbbbbbbbbbbb2222", "nnnnnnnnnnnn8888", t0.Add(time.Duration(c.at)*time.Minute),
|
||||
c.state, c.modules...)
|
||||
if c.state == walkDone && w.state(later) != Delivered {
|
||||
t.Fatalf("the later merge is %s", w.state(later))
|
||||
}
|
||||
w.later(20 * time.Minute)
|
||||
if err := w.h.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.settleAll()
|
||||
if c.forge == "away" {
|
||||
// A forge that cannot say is "not yet covered": held, and asked again a minute on.
|
||||
if s := w.state(IDOf(ctl, head)); s != Held {
|
||||
t.Fatalf("with the forge away it is %s", s)
|
||||
}
|
||||
w.forge.mu.Lock()
|
||||
w.forge.containsDown = false
|
||||
w.forge.mu.Unlock()
|
||||
w.later(2 * time.Minute)
|
||||
w.settleAll()
|
||||
}
|
||||
d := w.delivery(IDOf(ctl, head))
|
||||
if d.State != c.want {
|
||||
t.Fatalf("it is %s (%s), want %s", d.State, d.HeldWhy, c.want)
|
||||
}
|
||||
s := statusOn(w, ctl, head)
|
||||
if c.want == Covered {
|
||||
if !strings.HasPrefix(s, "success covered: a later merge that contains it was delivered") ||
|
||||
!strings.Contains(s, "nnnnnnnn") {
|
||||
t.Fatalf("its pull request's mesh/delivery says %q", s)
|
||||
}
|
||||
last := d.Transitions[len(d.Transitions)-1]
|
||||
if !strings.Contains(last.Why, IDOf(ctl, "bbbbbbbbbbbb2222")) {
|
||||
t.Fatalf("its transition does not name what covered it: %q", last.Why)
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(s, "error") {
|
||||
t.Fatalf("its pull request's mesh/delivery is an error: %q", s)
|
||||
}
|
||||
// Asked once for the pair, however many ticks read it — once more only after the forge was away.
|
||||
asked := 0
|
||||
for _, a := range w.forge.containsAsks {
|
||||
if a == pair {
|
||||
asked++
|
||||
}
|
||||
}
|
||||
candidate := c.base == "main" && c.at > 30 && c.state == walkDone && len(c.modules) >= 2
|
||||
switch {
|
||||
case !candidate && asked != 0:
|
||||
t.Fatalf("the forge was asked whether %s for a merge order and walk that cannot cover it", pair)
|
||||
case candidate && c.forge != "away" && asked != 1:
|
||||
t.Fatalf("the forge was asked %d time(s) whether %s, want once", asked, pair)
|
||||
case candidate && c.forge == "away" && asked < 2:
|
||||
t.Fatalf("the forge, away, was asked %d time(s) whether %s: never again", asked, pair)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A covered member of a group counts as delivered: the member after it is let go, not stopped.
|
||||
func TestACoveredGroupMemberLetsTheNextOneGo(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
const ctl, lab = "novox/mesh-controller", "novox/mesh-lab"
|
||||
const ctlHead, labHead = "c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2"
|
||||
ctlID, labID := IDOf(ctl, ctlHead), IDOf(lab, labHead)
|
||||
pc, pl := pr(ctl, 1, ctlHead, "feat/g", "cmd/a.go"), pr(lab, 2, labHead, "feat/g", "replays/a.go")
|
||||
w.h.PullUpdated(pc)
|
||||
w.h.PullUpdated(pl)
|
||||
w.h.Checked(verdictMoving(ctl, 1, ctlHead, "mesh-controller"))
|
||||
w.h.Checked(verdictMoving(lab, 2, labHead, "mesh-lab"))
|
||||
w.settleAll()
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: "check-1", Verdict: "pass", Summary: "both compose"})
|
||||
w.later(31 * time.Minute)
|
||||
w.h.PullMerged(mergedAt(pc, "m1m1m1m1m1m1", t0.Add(30*time.Minute)))
|
||||
w.h.PullMerged(mergedAt(pl, "m2m2m2m2m2m2", t0.Add(31*time.Minute)))
|
||||
w.h.WalkMoved(w.ctl.put(aWalk("plan-lab", lab, "m2m2m2m2m2m2", true)))
|
||||
w.settleAll()
|
||||
w.forge.contains["m1m1m1m1m1m1 in m3m3m3m3m3m3"] = true
|
||||
deliverLater(w, ctl, 3, "main", "e3e3e3e3e3e3e3e3", "m3m3m3m3m3m3", t0.Add(35*time.Minute), walkDone, "mesh-controller")
|
||||
w.later(20 * time.Minute)
|
||||
if err := w.h.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.settleAll()
|
||||
if w.state(ctlID) != Covered {
|
||||
t.Fatalf("the controller's member is %s", w.state(ctlID))
|
||||
}
|
||||
if w.state(labID) != Delivering || !containsString(w.ctl.delivers, "plan-lab") {
|
||||
t.Fatalf("the member after it is %s, let go %v", w.state(labID), w.ctl.delivers)
|
||||
}
|
||||
lw := w.ctl.walk("plan-lab")
|
||||
lw.State = walkDone
|
||||
w.h.WalkMoved(w.ctl.put(lw))
|
||||
w.settleAll()
|
||||
if state, why := GroupState(w.h.groups["feat/g"], w.h.membersOf(w.h.groups["feat/g"])); state != "delivered" {
|
||||
t.Fatalf("the group is %s: %s", state, why)
|
||||
}
|
||||
}
|
||||
|
||||
// **On rollout, the deliveries already held close by themselves** (issue 319's held ones), and a restart
|
||||
// after that moves nothing again: what was read back is where it stays.
|
||||
func TestHeldDeliveriesReadBackCloseByThemselvesOnce(t *testing.T) {
|
||||
const ctl, cat = "novox/mesh-controller", "novox/mesh-catalog"
|
||||
merged1, merged2 := t0, t0.Add(2*time.Hour)
|
||||
coverer := func(repo, module string) *Delivery {
|
||||
return &Delivery{ID: IDOf(repo, "c0c0c0c0c0c0c0c0"), Repository: repo, Commit: "c0c0c0c0c0c0c0c0", Number: 90,
|
||||
Base: "main", State: Delivered, Since: merged2.Add(time.Hour), Created: merged2.Add(-time.Hour),
|
||||
MergedAs: "f0f0f0f0f0f0f0f0", MergedAt: merged2, Plan: planMoving(repo, module), Check: &Verdict{Gate: "pass"},
|
||||
Walk: &WalkSeen{ID: "plan-90", Commit: "f0f0f0f0f0f0f0f0", State: walkDone},
|
||||
Steps: []Step{{Machine: "anchor", Module: module, State: StepPassed}, {Machine: "the rest running it", Module: module, State: StepSent}}}
|
||||
}
|
||||
held := func(repo string, plan *DeliveryPlan, why string) *Delivery {
|
||||
return &Delivery{ID: IDOf(repo, head), Repository: repo, Commit: head, Number: 7, Base: "main", State: Held,
|
||||
Since: merged1.Add(10 * time.Minute), Created: merged1.Add(-time.Hour), MergedAs: merge, MergedAt: merged1,
|
||||
HeldWhy: why, Plan: plan, Transitions: []Transition{{At: merged1, From: Ready, To: Held, Event: EvHold, Why: why}}}
|
||||
}
|
||||
const unwalkedWhy = "merged, and the controller opened no walk for it within 10m0s — nothing it holds follows that branch, " +
|
||||
"or the merge was not heard: a person decides"
|
||||
withCoverer := func(d *Delivery, change func(c *Delivery)) []*Delivery {
|
||||
c := coverer(d.Repository, d.Plan.Moved[0])
|
||||
change(c)
|
||||
return []*Delivery{d, c}
|
||||
}
|
||||
const uncheckedWhy = "merged without a passing check: only a person decides that it goes on"
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
kept []*Delivery
|
||||
group *Group
|
||||
want State
|
||||
}{
|
||||
{"merged without a passing check, builds nothing",
|
||||
[]*Delivery{held("novox/hq", planMoving("novox/hq"), "merged without a passing check: only a person decides that it goes on")},
|
||||
nil, Delivered},
|
||||
{"a new module sent nowhere, no walk opened",
|
||||
[]*Delivery{held(cat, &DeliveryPlan{New: []string{"modules/zfs"}, Summary: "builds new: modules/zfs, sent nowhere"}, unwalkedWhy)},
|
||||
nil, Delivered},
|
||||
{"no walk opened, a later delivered walk carried its module",
|
||||
[]*Delivery{held(ctl, planMoving(ctl, "mesh-controller"), unwalkedWhy), coverer(ctl, "mesh-controller")}, nil, Covered},
|
||||
{"merged without a passing check, a later delivered walk whose own check passed carried its module",
|
||||
withCoverer(held(ctl, planMoving(ctl, "mesh-controller"), uncheckedWhy), func(*Delivery) {}), nil, Covered},
|
||||
{"merged without a passing check, the later delivery's own check did not pass",
|
||||
withCoverer(held(ctl, planMoving(ctl, "mesh-controller"), uncheckedWhy), func(c *Delivery) {
|
||||
c.Check = &Verdict{Gate: "fail"}
|
||||
}), nil, Held},
|
||||
{"no walk opened, the later delivery's walk was of another commit",
|
||||
withCoverer(held(ctl, planMoving(ctl, "mesh-controller"), unwalkedWhy), func(c *Delivery) {
|
||||
c.Walk.Commit = "e0e0e0e0e0e0e0e0"
|
||||
}), nil, Held},
|
||||
{"no walk opened, the later walk sent its module to its first machine only",
|
||||
withCoverer(held(ctl, planMoving(ctl, "mesh-controller"), unwalkedWhy), func(c *Delivery) {
|
||||
c.Steps = c.Steps[:1]
|
||||
}), nil, Held},
|
||||
{"no walk opened, the later walk's branch is another",
|
||||
withCoverer(held(ctl, planMoving(ctl, "mesh-controller"), unwalkedWhy), func(c *Delivery) {
|
||||
c.Walk.Branch = "release"
|
||||
}), nil, Held},
|
||||
{"no walk opened, a later delivered walk carried another module",
|
||||
[]*Delivery{held(ctl, planMoving(ctl, "mesh-controller"), unwalkedWhy), coverer(ctl, "route-proxy")}, nil, Held},
|
||||
{"held for its group, its own walk superseded, a later delivered walk carried its module",
|
||||
func() []*Delivery {
|
||||
d := held(cat, planMoving(cat, "systemd"), "its group feat/j's composed check did not pass for the heads that "+
|
||||
"merged; a person decides that it goes on")
|
||||
d.Group = "feat/j"
|
||||
d.Walk = &WalkSeen{ID: "plan-7", Commit: merge, State: walkSuperseded, Waits: true, WaitedFor: "mesh-delivery"}
|
||||
other := &Delivery{ID: IDOf("novox/mesh-host", "abababababababab"), Repository: "novox/mesh-host",
|
||||
Commit: "abababababababab", Number: 3, Base: "main", Group: "feat/j", State: Delivered,
|
||||
MergedAs: "acacacacacacacac", MergedAt: merged1}
|
||||
return []*Delivery{d, other, coverer(cat, "systemd")}
|
||||
}(),
|
||||
&Group{ID: "feat/j", Members: []string{IDOf(cat, head), IDOf("novox/mesh-host", "abababababababab")}, Closed: true,
|
||||
Check: &GroupCheck{Asked: "check-1", Verdict: "pass", Commits: map[string]string{IDOf(cat, head): head,
|
||||
IDOf("novox/mesh-host", "abababababababab"): "abababababababab"}}},
|
||||
Covered},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
store := newMemStore()
|
||||
for _, d := range c.kept {
|
||||
if err := store.PutDelivery(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if c.group != nil {
|
||||
if err := store.PutGroup(c.group); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
now := merged2.Add(30 * time.Hour)
|
||||
forge := newFakeForge()
|
||||
forge.contains[merge+" in f0f0f0f0f0f0f0f0"] = true
|
||||
w := newWorldOver(t, store, newFakeController(), forge, now)
|
||||
if err := w.h.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.settleAll()
|
||||
id := c.kept[0].ID
|
||||
d := w.delivery(id)
|
||||
if d.State != c.want {
|
||||
t.Fatalf("read back held, it is %s, want %s", d.State, c.want)
|
||||
}
|
||||
if s := statusOn(w, d.Repository, d.Commit); c.want != Held && !strings.HasPrefix(s, "success") {
|
||||
t.Fatalf("its pull request's mesh/delivery says %q", s)
|
||||
}
|
||||
// A restart over what it kept: nothing moves again.
|
||||
again := newWorldOver(t, store, newFakeController(), forge, now.Add(time.Hour))
|
||||
if err := again.h.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again.settleAll()
|
||||
if d2 := again.delivery(id); d2.State != d.State || len(d2.Transitions) != len(d.Transitions) {
|
||||
t.Fatalf("a restart moved it again: %s with %d transitions, was %s with %d", d2.State, len(d2.Transitions),
|
||||
d.State, len(d.Transitions))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// **A module a release carried to every machine the held delivery sends it to covers it** (novox/hq issue
|
||||
// 351). A build the controller held back for a person and released on their word reaches each machine by a
|
||||
// backlog walk, which credits the delivery that published the build one step per machine — never the walk's
|
||||
// own "every machine running it" or "the rest running it". The held delivery's plan says which machines
|
||||
// receive the module: a passed step on each of them is the module sent everywhere. A machine still judging,
|
||||
// one that failed, or a plan that names no machines leaves it held, as before.
|
||||
func TestAModuleAReleaseCarriedToEveryMachineCovers(t *testing.T) {
|
||||
const cat = "novox/mesh-catalog"
|
||||
merged1, merged2 := t0, t0.Add(2*time.Hour)
|
||||
laptop, desktop := "laptop", "desktop"
|
||||
plan := func(machines ...string) *DeliveryPlan {
|
||||
p := planMoving(cat, "openrazer")
|
||||
for _, m := range machines {
|
||||
p.Machines = append(p.Machines, MachinePlan{Machine: m, Receives: []string{"openrazer"}})
|
||||
}
|
||||
return p
|
||||
}
|
||||
held := func(p *DeliveryPlan) *Delivery {
|
||||
why := "its group feat/j's composed check did not pass for the heads that merged; a person decides that it goes on"
|
||||
return &Delivery{ID: IDOf(cat, head), Repository: cat, Commit: head, Number: 7, Base: "main", State: Held,
|
||||
Since: merged1.Add(10 * time.Minute), Created: merged1.Add(-time.Hour), MergedAs: merge, MergedAt: merged1,
|
||||
HeldWhy: why, Plan: p, Walk: &WalkSeen{ID: "plan-7", Commit: merge, State: walkSuperseded, Waits: true, WaitedFor: "mesh-delivery"},
|
||||
Transitions: []Transition{{At: merged1, From: Published, To: Held, Event: EvHold, Why: why}}}
|
||||
}
|
||||
step := func(machine string, state StepState) Step {
|
||||
return Step{Machine: machine, Module: "openrazer", Build: "build-1", State: state, Why: "carried by release-1"}
|
||||
}
|
||||
coverer := func(steps ...Step) *Delivery {
|
||||
return &Delivery{ID: IDOf(cat, "c0c0c0c0c0c0c0c0"), Repository: cat, Commit: "c0c0c0c0c0c0c0c0", Number: 90,
|
||||
Base: "main", State: Delivered, Since: merged2.Add(time.Hour), Created: merged2.Add(-time.Hour),
|
||||
MergedAs: "f0f0f0f0f0f0f0f0", MergedAt: merged2, Plan: planMoving(cat, "power"), Check: &Verdict{Gate: "pass"},
|
||||
Walk: &WalkSeen{ID: "plan-90", Commit: "f0f0f0f0f0f0f0f0", State: walkDone},
|
||||
Steps: append([]Step{{Machine: "anchor", Module: "power", State: StepPassed},
|
||||
{Machine: "the rest running it", Module: "power", State: StepSent}}, steps...)}
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
held *Delivery
|
||||
by *Delivery
|
||||
want State
|
||||
}{
|
||||
{"passed on every machine its plan sends it to", held(plan(laptop, desktop)),
|
||||
coverer(step(laptop, StepPassed), step(desktop, StepPassed)), Covered},
|
||||
{"still judging on one of them", held(plan(laptop, desktop)),
|
||||
coverer(step(laptop, StepPassed), step(desktop, StepJudging)), Held},
|
||||
{"failed on one of them", held(plan(laptop, desktop)),
|
||||
coverer(step(laptop, StepPassed), step(desktop, StepFailed)), Held},
|
||||
{"never sent to one of them", held(plan(laptop, desktop)), coverer(step(laptop, StepPassed)), Held},
|
||||
{"its plan names no machines", held(plan()), coverer(step(laptop, StepPassed), step(desktop, StepPassed)), Held},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
store := newMemStore()
|
||||
for _, d := range []*Delivery{c.held, c.by} {
|
||||
if err := store.PutDelivery(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
forge := newFakeForge()
|
||||
forge.contains[merge+" in f0f0f0f0f0f0f0f0"] = true
|
||||
w := newWorldOver(t, store, newFakeController(), forge, merged2.Add(30*time.Hour))
|
||||
if err := w.h.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.settleAll()
|
||||
d := w.delivery(c.held.ID)
|
||||
if d.State != c.want {
|
||||
t.Fatalf("it is %s (%s), want %s", d.State, d.HeldWhy, c.want)
|
||||
}
|
||||
if c.want == Covered {
|
||||
if s := statusOn(w, cat, head); !strings.HasPrefix(s, "success covered: a later merge that contains it was delivered") {
|
||||
t.Fatalf("its pull request's mesh/delivery says %q", s)
|
||||
}
|
||||
if last := d.Transitions[len(d.Transitions)-1]; !strings.Contains(last.Why, "openrazer") {
|
||||
t.Fatalf("its transition does not name the module: %q", last.Why)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// **A finished release moves the steps it carried to passed** (novox/hq issue 351): a backlog walk's gate
|
||||
// credits a step per machine while it judges, and its last record keeps no gate — only which machines it is
|
||||
// done on. A step credited to it, on a machine it is done on, is passed; a step it failed on, one on a machine
|
||||
// it is not done on, and a step another walk carried are left as they are.
|
||||
func TestAFinishedReleaseMovesTheStepsItCarriedToPassed(t *testing.T) {
|
||||
const cat = "novox/mesh-catalog"
|
||||
carried := func(machine string, state StepState, by string) Step {
|
||||
return Step{Machine: machine, Module: "openrazer", Build: "build-1", State: state, Why: "carried by " + by}
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
steps []Step
|
||||
done []string
|
||||
want map[string]StepState
|
||||
}{
|
||||
{"done on both", []Step{carried("laptop", StepJudging, "release-1"), carried("desktop", StepJudging, "release-1")},
|
||||
[]string{"laptop", "desktop"}, map[string]StepState{"laptop": StepPassed, "desktop": StepPassed}},
|
||||
{"done on one, the other still to come", []Step{carried("laptop", StepJudging, "release-1"), carried("desktop", StepJudging, "release-1")},
|
||||
[]string{"laptop"}, map[string]StepState{"laptop": StepPassed, "desktop": StepJudging}},
|
||||
{"a step it failed on stays failed", []Step{carried("laptop", StepFailed, "release-1")},
|
||||
[]string{"laptop"}, map[string]StepState{"laptop": StepFailed}},
|
||||
{"a step another walk carried is not its", []Step{carried("laptop", StepJudging, "release-2")},
|
||||
[]string{"laptop"}, map[string]StepState{"laptop": StepJudging}},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
d := &Delivery{ID: IDOf(cat, "c0c0c0c0c0c0c0c0"), Repository: cat, Commit: "c0c0c0c0c0c0c0c0", Number: 90,
|
||||
Base: "main", State: Delivered, Since: t0.Add(time.Hour), Created: t0, MergedAs: "f0f0f0f0f0f0f0f0", MergedAt: t0,
|
||||
Plan: planMoving(cat, "power"), Check: &Verdict{Gate: "pass"},
|
||||
Walk: &WalkSeen{ID: "plan-90", Commit: "f0f0f0f0f0f0f0f0", State: walkDone}, Steps: c.steps}
|
||||
store := newMemStore()
|
||||
if err := store.PutDelivery(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := newWorldOver(t, store, newFakeController(), newFakeForge(), t0.Add(2*time.Hour))
|
||||
w.h.WalkMoved(Walk{ID: "release-1", Repository: "the builds waiting for a gate", State: walkDone,
|
||||
Tiers: [][]string{{"openrazer"}}, Modules: map[string]*WalkModule{},
|
||||
Release: &WalkRelease{Order: []string{"laptop", "desktop"}, Next: len(c.done), Done: c.done}})
|
||||
got := map[string]StepState{}
|
||||
for _, s := range w.delivery(d.ID).Steps {
|
||||
got[s.Machine] = s.State
|
||||
}
|
||||
for machine, want := range c.want {
|
||||
if got[machine] != want {
|
||||
t.Fatalf("openrazer on %s is %s, want %s (steps %v)", machine, got[machine], want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The day's case whole (novox/hq issue 351): a member held for its group's check, its own walk taken over by
|
||||
// later merges; the one later walk that carried its module failed its gate; a later merge's walk was delivered
|
||||
// without the module; the controller then held the module's build back for a person, who released it, and the
|
||||
// release reached both machines. The held delivery is covered by that later merge once the release is done —
|
||||
// read by its id, since a walk that ended before the holder's restart may be older than the ended walks the
|
||||
// controller lists back.
|
||||
func TestAHeldDeliveryWhoseModuleAReleaseCarriedIsCoveredOnceTheReleaseIsDone(t *testing.T) {
|
||||
const cat = "novox/mesh-catalog"
|
||||
merged1, merged2 := t0, t0.Add(2*time.Hour)
|
||||
plan := planMoving(cat, "openrazer")
|
||||
plan.Machines = []MachinePlan{{Machine: "laptop", Receives: []string{"openrazer"}}, {Machine: "desktop", Receives: []string{"openrazer"}}}
|
||||
why := "its group feat/j's composed check did not pass for the heads that merged; a person decides that it goes on"
|
||||
held := &Delivery{ID: IDOf(cat, head), Repository: cat, Commit: head, Number: 7, Base: "main", State: Held,
|
||||
Since: merged1.Add(10 * time.Minute), Created: merged1.Add(-time.Hour), MergedAs: merge, MergedAt: merged1,
|
||||
HeldWhy: why, Plan: plan, Walk: &WalkSeen{ID: "plan-7", Commit: merge, State: walkSuperseded, Waits: true, WaitedFor: "mesh-delivery"},
|
||||
Transitions: []Transition{{At: merged1, From: Published, To: Held, Event: EvHold, Why: why}}}
|
||||
later := &Delivery{ID: IDOf(cat, "c0c0c0c0c0c0c0c0"), Repository: cat, Commit: "c0c0c0c0c0c0c0c0", Number: 90,
|
||||
Base: "main", State: Delivered, Since: merged2.Add(time.Hour), Created: merged2.Add(-time.Hour),
|
||||
MergedAs: "f0f0f0f0f0f0f0f0", MergedAt: merged2, Plan: planMoving(cat, "power"), Check: &Verdict{Gate: "pass"},
|
||||
Walk: &WalkSeen{ID: "plan-90", Commit: "f0f0f0f0f0f0f0f0", State: walkDone},
|
||||
Steps: []Step{{Machine: "anchor", Module: "power", State: StepPassed}, {Machine: "the rest running it", Module: "power", State: StepSent}}}
|
||||
store := newMemStore()
|
||||
for _, d := range []*Delivery{held, later} {
|
||||
if err := store.PutDelivery(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
forge := newFakeForge()
|
||||
forge.contains[merge+" in f0f0f0f0f0f0f0f0"] = true
|
||||
w := newWorldOver(t, store, newFakeController(), forge, merged2.Add(3*time.Hour))
|
||||
if err := w.h.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.settleAll()
|
||||
if s := w.state(held.ID); s != Held {
|
||||
t.Fatalf("before any release it is %s", s)
|
||||
}
|
||||
// The release judges the build on each machine in turn: its gate names what it carried.
|
||||
release := Walk{ID: "release-1", Repository: "the builds waiting for a gate", State: walkRolling,
|
||||
Tiers: [][]string{{"openrazer"}}, Modules: map[string]*WalkModule{},
|
||||
Release: &WalkRelease{Order: []string{"laptop", "desktop"}, Gate: &WalkGate{Machines: []string{"laptop"},
|
||||
Carried: []WalkCarry{{Module: "openrazer", Node: "laptop", To: "f0f0f0f0f0f0f0f0", Build: "build-1"},
|
||||
{Module: "openrazer", Node: "desktop", To: "f0f0f0f0f0f0f0f0", Build: "build-1"}}}}}
|
||||
w.h.WalkMoved(release)
|
||||
w.settleAll()
|
||||
if s := w.state(held.ID); s != Held {
|
||||
t.Fatalf("while the release judges it is %s", s)
|
||||
}
|
||||
// Done on both: its last record keeps no gate. It ended while this holder was down, and is older than the
|
||||
// ended walks the controller lists back: the holder asks for it by its id, since a step it carried still judges.
|
||||
release.State, release.Release.Gate, release.Release.Done, release.Release.Next = walkDone, nil, []string{"laptop", "desktop"}, 2
|
||||
w.ctl.put(release)
|
||||
w.later(time.Hour) // a walk a step still judges is asked for once an hour; at a start, at once
|
||||
w.settleAll()
|
||||
d := w.delivery(held.ID)
|
||||
if d.State != Covered {
|
||||
t.Fatalf("once the release is done it is %s (%s), want covered", d.State, d.HeldWhy)
|
||||
}
|
||||
if last := d.Transitions[len(d.Transitions)-1]; !strings.Contains(last.Why, IDOf(cat, "c0c0c0c0c0c0c0c0")) ||
|
||||
!strings.Contains(last.Why, "openrazer") {
|
||||
t.Fatalf("its transition does not name what covered it: %q", last.Why)
|
||||
}
|
||||
}
|
||||
@@ -31,15 +31,6 @@ func transitionEvent(d *Delivery, t Transition, opened time.Time) map[string]any
|
||||
if d.MergedAs != "" {
|
||||
e["merged_as"] = d.MergedAs
|
||||
}
|
||||
if d.Walk != nil {
|
||||
e["walk"] = d.Walk.ID
|
||||
}
|
||||
if d.Plan != nil {
|
||||
e["plan"] = d.Plan.Summary
|
||||
}
|
||||
if c := fixClass(d); c != "" {
|
||||
e["class"] = c
|
||||
}
|
||||
if !opened.IsZero() {
|
||||
e["opened"] = opened.UTC()
|
||||
}
|
||||
@@ -49,24 +40,6 @@ func transitionEvent(d *Delivery, t Transition, opened time.Time) map[string]any
|
||||
return e
|
||||
}
|
||||
|
||||
// fixClass is the class of what a delivery moves (novox/hq ADR 0313 §2): its plan's, as the planner said it;
|
||||
// none for a plan that moves no module, from a planner that did not say a class; else its walk's class once its
|
||||
// delivery time is known; empty when nothing says it.
|
||||
func fixClass(d *Delivery) string {
|
||||
switch {
|
||||
case d.Plan != nil && d.Plan.Class != "":
|
||||
return d.Plan.Class
|
||||
case d.Plan != nil && len(d.Plan.Moved) == 0 && len(d.Plan.Dependents) == 0 && len(d.Plan.New) == 0:
|
||||
return classNone
|
||||
case d.Time != nil:
|
||||
return d.Time.Class
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// classNone is the class of a delivery that moves no module: nothing for a walk to move, and no fix budget.
|
||||
const classNone = "none"
|
||||
|
||||
// openedOf is when a delivery's pull request was first heard: the earliest head of it this owner holds, never
|
||||
// one made from the forge's word at its merge; zero when none is held or it has no pull request. Under the lock.
|
||||
func (h *Holder) openedOf(d *Delivery) time.Time {
|
||||
@@ -96,88 +69,50 @@ func noteLine(d *Delivery, t Transition) string {
|
||||
}
|
||||
line := fmt.Sprintf("%s delivery %s: %s -> %s (%s%s): %s", t.At.UTC().Format(time.RFC3339), d.ID, orNothing(t.From),
|
||||
t.To, t.Event, by, t.Why)
|
||||
if t.To == Proposed && d.Plan != nil {
|
||||
line += "; predicted: " + d.Plan.Summary
|
||||
}
|
||||
if d.Group != "" && (t.To == Proposed || t.To == Published) {
|
||||
if d.Group != "" && (t.To == Proposed || t.To == Merged) {
|
||||
line += "; group " + d.Group
|
||||
}
|
||||
return oneLine(line)
|
||||
}
|
||||
|
||||
// executedLine is what a delivery did, written on the commit it landed as when it ends.
|
||||
func executedLine(d *Delivery) string {
|
||||
var parts []string
|
||||
for _, s := range d.Steps {
|
||||
parts = append(parts, fmt.Sprintf("%s on %s %s", s.Module, s.Machine, s.State))
|
||||
}
|
||||
walk := ""
|
||||
if d.Walk != nil {
|
||||
walk = " (walk " + d.Walk.ID + ")"
|
||||
}
|
||||
return oneLine(fmt.Sprintf("%s delivery %s executed%s: %s", time.Now().UTC().Format(time.RFC3339), d.ID, walk,
|
||||
strings.Join(parts, "; ")))
|
||||
}
|
||||
|
||||
func oneLine(s string) string { return strings.Join(strings.Fields(s), " ") }
|
||||
|
||||
// statusOf is the commit status `mesh/delivery`: where the delivery stands, in the forge's words.
|
||||
// statusOf is the commit status `mesh/delivery`: where the delivery stands, in the forge's words. It says the merge's
|
||||
// states only (ADR 0539 §17): whether a merged change reached the machines is the view's.
|
||||
func statusOf(d *Delivery) (string, string) {
|
||||
why := ""
|
||||
if len(d.Transitions) > 0 {
|
||||
why = d.Transitions[len(d.Transitions)-1].Why
|
||||
}
|
||||
switch d.State {
|
||||
case Proposed, Checked:
|
||||
case Proposed:
|
||||
return "pending", "checking"
|
||||
case Ready:
|
||||
if d.holdsForReview() {
|
||||
return "pending", clip("ready, "+d.Ready.Waits, 140) // novox/hq ADR 0496: no Approve is asked before a review
|
||||
}
|
||||
return "success", "ready: it delivers once merged"
|
||||
return "success", "ready: it is merged on the Approve"
|
||||
case Rejected:
|
||||
return "failure", clip("rejected: "+why, 140)
|
||||
case Published:
|
||||
return "pending", "published: its walk waits for its turn"
|
||||
case Merged:
|
||||
return "success", clip("merged: "+d.sourceSaid(), 140)
|
||||
case Held:
|
||||
return "pending", clip("held for a person: "+d.HeldWhy, 140)
|
||||
case Delivering:
|
||||
passed := 0
|
||||
for _, s := range d.Steps {
|
||||
if s.State == StepPassed {
|
||||
passed++
|
||||
}
|
||||
}
|
||||
return "pending", fmt.Sprintf("delivering: %d machine step(s) passed", passed)
|
||||
case Delivered:
|
||||
return "success", "delivered"
|
||||
case Failed:
|
||||
if d.coveredStop() {
|
||||
return "success", clip(d.coveredStopSaid(), 140) // its change reached the machines (novox/hq issue 747)
|
||||
}
|
||||
return "failure", clip("failed: "+why, 140)
|
||||
case Superseded:
|
||||
if d.coveredStop() {
|
||||
return "success", clip(d.coveredStopSaid(), 140) // its walk's taker delivered it (issue 1786)
|
||||
}
|
||||
return "warning", clip("superseded: "+why, 140)
|
||||
case Stopped:
|
||||
if d.coveredStop() {
|
||||
return "success", clip(d.coveredStopSaid(), 140) // its change reached the machines (novox/hq issue 480)
|
||||
}
|
||||
return "error", clip("stopped: "+why, 140)
|
||||
case Retired:
|
||||
return "success", "history: merged before this owner delivered anything, never walked"
|
||||
case Covered:
|
||||
by := d.CoveredBy
|
||||
if by == "" {
|
||||
by = d.Carrier
|
||||
}
|
||||
return "success", clip("covered: a later merge that contains it was delivered: "+by, 140)
|
||||
case Closed:
|
||||
return "warning", clip("closed: "+why, 140)
|
||||
}
|
||||
return "pending", string(d.State)
|
||||
}
|
||||
|
||||
// sourceSaid names the source revision a merge moved: the trunk commit of its repository that source.main now holds
|
||||
// (ADR 0539 §17: the gate's last word on a change; what the machines run is the view's).
|
||||
func (d *Delivery) sourceSaid() string {
|
||||
if d.MergedAs == "" {
|
||||
return "not on the trunk"
|
||||
}
|
||||
return "source.main moved to " + d.Repository + "@" + shortOf(d.MergedAs, 12)
|
||||
}
|
||||
|
||||
// viewMarker is how the forge's holder finds the one view comment of a pull request.
|
||||
const viewMarker = "<!-- mesh-delivery:view -->"
|
||||
|
||||
@@ -185,31 +120,12 @@ const viewMarker = "<!-- mesh-delivery:view -->"
|
||||
func ViewBody(d *Delivery, g *Group) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%s\n**Delivery** `%s` — **%s** since %s\n\n", viewMarker, d.ID, d.State, d.Since.UTC().Format(time.RFC3339))
|
||||
if d.coveredStop() {
|
||||
fmt.Fprintf(&b, "**Covered**: %s\n\n", d.coveredStopSaid())
|
||||
}
|
||||
if d.HeldWhy != "" && d.State == Held {
|
||||
fmt.Fprintf(&b, "Held for a person: %s — `mesh-delivery.release` with why lets it go on.\n\n", d.HeldWhy)
|
||||
fmt.Fprintf(&b, "Held for a person: %s — the merge reached the trunk without a passing check.\n\n", d.HeldWhy)
|
||||
}
|
||||
if d.Plan != nil {
|
||||
fmt.Fprintf(&b, "**Delivery plan** — %s\n", d.Plan.Summary)
|
||||
for i, tier := range d.Plan.Tiers {
|
||||
fmt.Fprintf(&b, "- tier %d: %s\n", i, strings.Join(tier, ", "))
|
||||
}
|
||||
for _, m := range d.Plan.Machines {
|
||||
var parts []string
|
||||
if len(m.Receives) > 0 {
|
||||
parts = append(parts, "receives "+strings.Join(m.Receives, ", "))
|
||||
}
|
||||
if len(m.Waits) > 0 {
|
||||
parts = append(parts, "waits for a person: "+strings.Join(m.Waits, ", "))
|
||||
}
|
||||
fmt.Fprintf(&b, "- %s: %s\n", m.Machine, strings.Join(parts, "; "))
|
||||
}
|
||||
for _, s := range d.Plan.Steps {
|
||||
fmt.Fprintf(&b, "- %s\n", s)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
if d.MergedAs != "" {
|
||||
fmt.Fprintf(&b, "**Source** — %s. Whether it reached the machines is `mesh-delivery-view`'s, derived from "+
|
||||
"`source.main`, the wants and the haves.\n\n", d.sourceSaid())
|
||||
}
|
||||
if g != nil {
|
||||
fmt.Fprintf(&b, "**Group** `%s`: %s\n", g.ID, strings.Join(g.Members, ", "))
|
||||
@@ -218,17 +134,6 @@ func ViewBody(d *Delivery, g *Group) string {
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if len(d.Steps) > 0 {
|
||||
b.WriteString("**Machines**\n")
|
||||
for _, s := range d.Steps {
|
||||
fmt.Fprintf(&b, "- %s on %s: %s", s.Module, s.Machine, s.State)
|
||||
if s.Why != "" {
|
||||
fmt.Fprintf(&b, " — %s", s.Why)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("**Transitions**\n")
|
||||
from := 0
|
||||
if len(d.Transitions) > 12 {
|
||||
@@ -237,7 +142,7 @@ func ViewBody(d *Delivery, g *Group) string {
|
||||
for _, t := range d.Transitions[from:] {
|
||||
fmt.Fprintf(&b, "- %s %s → %s (%s): %s\n", t.At.UTC().Format("2006-01-02 15:04"), orNothing(t.From), t.To, t.Event, t.Why)
|
||||
}
|
||||
b.WriteString("\nThe commit's note under `refs/notes/mesh-plan` keeps every transition: `git log --notes=mesh-plan`.\n")
|
||||
b.WriteString("\nThe commit's note under `refs/notes/mesh-plan` keeps every transition of this change up to its merge: `git log --notes=mesh-plan`.\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// R6-C4 (ADR 0539 §17): the delivery's status and view say the merge's six states and nothing after the merge: no delivery plan,
|
||||
// no machine step, no covered or walk line; a merged delivery's view names the source revision its merge moved.
|
||||
func TestStatusAndViewSayEachOfTheSixStatesWithNoPlanAndNoMachineStep(t *testing.T) {
|
||||
at := time.Date(2026, 10, 24, 12, 0, 0, 0, time.UTC)
|
||||
base := Delivery{ID: "novox/app@c0ffee000000", Repository: "novox/app", Commit: "c0ffee000000", Number: 7, Since: at,
|
||||
Transitions: []Transition{{At: at, From: Proposed, To: Rejected, Event: EvRefused, Why: "the gate said fail"}}}
|
||||
for _, c := range []struct {
|
||||
state State
|
||||
mutate func(d *Delivery)
|
||||
status string
|
||||
desc string
|
||||
inView []string
|
||||
keepOut []string
|
||||
}{
|
||||
{Proposed, nil, "pending", "checking", nil, nil},
|
||||
{Ready, nil, "success", "ready: it is merged on the Approve", nil, nil},
|
||||
{Rejected, nil, "failure", "rejected: the gate said fail", nil, nil},
|
||||
{Merged, func(d *Delivery) { d.MergedAs = "bbbbbbbbbbbb1111" }, "success",
|
||||
"merged: source.main moved to novox/app@bbbbbbbbbbbb", []string{"**Source**", "source.main moved to novox/app@bbbbbbbbbbbb", "mesh-delivery-view"}, nil},
|
||||
{Held, func(d *Delivery) { d.MergedAs, d.HeldWhy = "bbbbbbbbbbbb1111", "merged without a passing check" }, "pending",
|
||||
"held for a person: merged without a passing check", []string{"Held for a person: merged without a passing check"}, nil},
|
||||
{Closed, nil, "warning", "closed: the gate said fail", nil, nil},
|
||||
} {
|
||||
d := base
|
||||
d.State = c.state
|
||||
if c.mutate != nil {
|
||||
c.mutate(&d)
|
||||
}
|
||||
state, desc := statusOf(&d)
|
||||
if state != c.status || desc != c.desc {
|
||||
t.Errorf("%s: the status is %s %q, wanted %s %q", c.state, state, desc, c.status, c.desc)
|
||||
}
|
||||
view := ViewBody(&d, nil)
|
||||
if !strings.Contains(view, "**"+string(c.state)+"**") {
|
||||
t.Errorf("%s: the view does not say the state: %s", c.state, view)
|
||||
}
|
||||
for _, want := range c.inView {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Errorf("%s: the view lacks %q: %s", c.state, want, view)
|
||||
}
|
||||
}
|
||||
for _, never := range []string{"**Delivery plan**", "**Machines**", "tier ", "receives", "walk", "Covered", "plans go", "mesh-delivery.release"} {
|
||||
if strings.Contains(view, never) {
|
||||
t.Errorf("%s: the view still says %q: %s", c.state, never, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A merge not on the trunk names no source revision.
|
||||
d := base
|
||||
d.State = Merged
|
||||
if s := d.sourceSaid(); s != "not on the trunk" {
|
||||
t.Errorf("a merged delivery with no merge commit says %q", s)
|
||||
}
|
||||
}
|
||||
@@ -42,9 +42,7 @@ func (h *Holder) takeForgeWanted() bool {
|
||||
|
||||
// AnswersChanged takes a change of the teller's answers: the answers to the asks pending are read at the look it asks for.
|
||||
func (h *Holder) AnswersChanged() {
|
||||
if h.Holding() {
|
||||
h.Poke()
|
||||
}
|
||||
h.Poke()
|
||||
}
|
||||
|
||||
// CommitStatus takes a commit status set on the forge: one of the merge checks (mesh/check and, on a
|
||||
|
||||
@@ -19,7 +19,7 @@ func hear(t *testing.T, w *world, key string, body any) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if key == ControllerSeat+".checked" {
|
||||
if key == shimCheckedKey {
|
||||
// The gate follows no verdict of the controller any more (issue 1318): a test says it as the checker's.
|
||||
var c CheckedEvent
|
||||
if err := json.Unmarshal(raw, &c); err != nil {
|
||||
@@ -47,7 +47,7 @@ func TestAPassingVerdictAsksTheOperatorAtOnceWithNoMark(t *testing.T) {
|
||||
if w.op.count() != 0 {
|
||||
t.Fatalf("asked before the checks passed: %d", w.op.count())
|
||||
}
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
if w.op.count() != 1 {
|
||||
t.Fatalf("a pull request whose checks passed asked the operator %d times, wanted once, with no mark", w.op.count())
|
||||
}
|
||||
@@ -63,7 +63,7 @@ func TestAPassingVerdictAsksTheOperatorAtOnceWithNoMark(t *testing.T) {
|
||||
|
||||
func TestAFailedVerdictAsksNothing(t *testing.T) {
|
||||
w := announced(t)
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "fail", "t1t1t1t1t1t1"))
|
||||
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "fail", "t1t1t1t1t1t1"))
|
||||
if w.op.count() != 0 {
|
||||
t.Fatalf("a pull request whose checks failed asked the operator %d times", w.op.count())
|
||||
}
|
||||
@@ -78,7 +78,7 @@ func approve(w *world) {
|
||||
|
||||
func TestAnApproveHeardAsAnEventMergesAtOnceAtTheApprovedHead(t *testing.T) {
|
||||
w := announced(t)
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
if len(w.op.open()) != 1 || len(w.forge.merges) != 0 {
|
||||
t.Fatalf("asks %v, merges %v", w.op.open(), w.forge.merges)
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func TestAnApproveHeardAsAnEventMergesAtOnceAtTheApprovedHead(t *testing.T) {
|
||||
func TestAPassingVerdictAsksTheMergeAtOnceEvenJustAfterARefusal(t *testing.T) {
|
||||
w := announced(t)
|
||||
id := IDOf("novox/app", readyHead)
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.forge.refuse = errAskedAgain
|
||||
approve(w)
|
||||
if len(w.forge.merges) != 1 {
|
||||
@@ -104,7 +104,7 @@ func TestAPassingVerdictAsksTheMergeAtOnceEvenJustAfterARefusal(t *testing.T) {
|
||||
w.forge.refuse = nil
|
||||
w.h.PullMerged(merged("novox/app", 9, "9999999999999999", "t2t2t2t2t2t2"))
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t2t2t2t2t2t2"))
|
||||
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t2t2t2t2t2t2"))
|
||||
if len(w.forge.merges) != 2 || w.delivery(id).Ready.Merged == nil {
|
||||
t.Fatalf("the passing verdict did not ask the merge at once: %v %+v", w.forge.merges, w.delivery(id).Ready)
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func TestAHeadNotReviewedAtThisHeadIsNotAsked(t *testing.T) {
|
||||
p := pr("novox/app", 7, readyHead, "feat/x", "src/a.go")
|
||||
p.Body = c.body
|
||||
w.h.PullUpdated(p)
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
if w.op.count() != 0 {
|
||||
t.Fatalf("an unreviewed pull request was asked: %d", w.op.count())
|
||||
}
|
||||
@@ -144,7 +144,7 @@ func TestTheAskNamesTheReviewerOfTheHead(t *testing.T) {
|
||||
p := pr("novox/app", 7, readyHead, "feat/x", "src/a.go")
|
||||
p.Body = "Fixes issue 7.\n\nReviewed-by: lead@shanks at " + readyHead + "\n"
|
||||
w.h.PullUpdated(p)
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
a := w.op.ask(w.op.open()[0])
|
||||
if strings.Contains(a.Explanation, "not reviewed") || !strings.Contains(a.Explanation, "reviewed by lead") {
|
||||
t.Fatalf("a reviewed head is not said reviewed:\n%s", a.Explanation)
|
||||
@@ -167,7 +167,7 @@ func TestTheReviewLineCorrectedInTheDescriptionIsReadWithoutAPush(t *testing.T)
|
||||
// Issue 1022 (ADR 0421 §6): a push ends the Approve and says so once on the pull request.
|
||||
func TestAPushAfterTheAskEndsItAndSaysSoOnceOnThePullRequest(t *testing.T) {
|
||||
w := announced(t)
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
old := w.op.open()[0]
|
||||
w.h.PullUpdated(pr("novox/app", 7, pushed, "feat/x", "src/a.go"))
|
||||
w.h.React()
|
||||
@@ -180,7 +180,7 @@ func TestAPushAfterTheAskEndsItAndSaysSoOnceOnThePullRequest(t *testing.T) {
|
||||
t.Fatalf("the end of the Approve is not said once on the pull request: %v", w.forge.comments)
|
||||
}
|
||||
// The new head is asked on its own when its checks pass.
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, pushed, "pass", "t1t1t1t1t1t1"))
|
||||
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, pushed, "pass", "t1t1t1t1t1t1"))
|
||||
if open := w.op.open(); len(open) != 1 || open[0] == old {
|
||||
t.Fatalf("the new head was not asked afresh: %v", open)
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func (s *memStore) Deliveries() ([]*Delivery, error) {
|
||||
defer s.mu.Unlock()
|
||||
var out []*Delivery
|
||||
for key, raw := range s.deliveries {
|
||||
if key == carriedKey { // the gate's mark that today's state was carried (seat.go), as the real store skips it
|
||||
if key == carriedKey { // the mark an earlier owner's carry-over wrote, as the real store skips it
|
||||
continue
|
||||
}
|
||||
var d Delivery
|
||||
@@ -88,299 +88,6 @@ func (s *memStore) Groups() ([]*Group, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type fakeController struct {
|
||||
mu sync.Mutex
|
||||
walks map[string]Walk
|
||||
held bool
|
||||
checks []string
|
||||
asked int
|
||||
delivers []string
|
||||
// backupsAgain are the walks whose failed backups were asked again.
|
||||
backupsAgain []string
|
||||
// refuseBackupsAgain refuses the word to take a walk's backups again.
|
||||
refuseBackupsAgain bool
|
||||
stops []string
|
||||
// withouts are the stops that left merges out (novox/hq ADR 0299): "<walk> without <merge>,…".
|
||||
withouts []string
|
||||
// refuseWithout refuses every stop with merges to leave out, as a controller before ADR 0299 does; mergedAt are
|
||||
// the merges' times, by commit, for what a later merge contains.
|
||||
refuseWithout bool
|
||||
mergedAt map[string]time.Time
|
||||
// refuseStop refuses every whole stop too; moves are the modules each merge moves, by commit, for what a left-out
|
||||
// merge shares (ADR 0299).
|
||||
refuseStop bool
|
||||
moves map[string][]string
|
||||
plans int
|
||||
down bool
|
||||
// emptyCheck makes Check answer with no id and no error, as the port does when the answer holds none.
|
||||
emptyCheck bool
|
||||
// walkReads counts the reads of the controller's walks (Walks).
|
||||
walkReads int
|
||||
// checkReady is the ready flag of each member of each check asked.
|
||||
checkReady []bool
|
||||
// baseSHAs is the base head each member of each check asked was sent with.
|
||||
baseSHAs []string
|
||||
// refuseDeliver refuses every word, as a controller that will not take it does (review of novox/hq issue 753).
|
||||
refuseDeliver bool
|
||||
// onCheck runs as a check is asked, before it is answered: what the forge held at that moment.
|
||||
onCheck func()
|
||||
// planned is what Plan answers instead of "builds app", when set.
|
||||
planned *DeliveryPlan
|
||||
// forgot are the pull requests whose check builds were let go of (novox/hq ADR 0350 §5), "owner/repo#n".
|
||||
forgot []string
|
||||
}
|
||||
|
||||
// BackupsAgain records the word, and clears the walk's failed backups as the controller does.
|
||||
func (c *fakeController) BackupsAgain(walk, why string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.down {
|
||||
return errors.New("down")
|
||||
}
|
||||
if c.refuseBackupsAgain {
|
||||
return errors.New("the controller refuses the backups")
|
||||
}
|
||||
w, known := c.walks[walk]
|
||||
if !known || w.Backups == nil || w.Backups.State != "failed" {
|
||||
return fmt.Errorf("%s holds no failed backup", walk)
|
||||
}
|
||||
w.Backups = nil
|
||||
w.Revision++
|
||||
c.walks[walk] = w
|
||||
c.backupsAgain = append(c.backupsAgain, walk)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *fakeController) ForgetChecks(owner, repo string, number int) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.down {
|
||||
return errors.New("down")
|
||||
}
|
||||
c.forgot = append(c.forgot, fmt.Sprintf("%s/%s#%d", owner, repo, number))
|
||||
return nil
|
||||
}
|
||||
|
||||
func newFakeController() *fakeController {
|
||||
return &fakeController{walks: map[string]Walk{}, held: true, mergedAt: map[string]time.Time{},
|
||||
moves: map[string][]string{}}
|
||||
}
|
||||
|
||||
func (c *fakeController) Plan(repository, base, head string, paths, dirs, removed []string) (*DeliveryPlan, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.plans++
|
||||
if c.down {
|
||||
return nil, errors.New("down")
|
||||
}
|
||||
if c.planned != nil {
|
||||
p := *c.planned
|
||||
return &p, nil
|
||||
}
|
||||
return &DeliveryPlan{Repository: repository, Base: base, Head: head, Moved: []string{"app"}, Summary: "builds app"}, nil
|
||||
}
|
||||
func (c *fakeController) Deliver(walk, why string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.down {
|
||||
return errors.New("down")
|
||||
}
|
||||
if c.refuseDeliver {
|
||||
return errors.New("the controller refuses the word")
|
||||
}
|
||||
w, known := c.walks[walk]
|
||||
if !known || w.Delivery == nil || w.Delivery.Go != nil {
|
||||
return fmt.Errorf("%s waits for nobody", walk)
|
||||
}
|
||||
now := time.Now()
|
||||
w.Delivery.Go, w.Delivery.By, w.Delivery.Why = &now, byOwner, why
|
||||
w.Revision++
|
||||
c.walks[walk] = w
|
||||
c.delivers = append(c.delivers, walk)
|
||||
return nil
|
||||
}
|
||||
func (c *fakeController) Stop(walk, why, by string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.down {
|
||||
return errors.New("down")
|
||||
}
|
||||
c.stops = append(c.stops, walk)
|
||||
if c.refuseStop {
|
||||
return errors.New("the controller refuses")
|
||||
}
|
||||
if w, known := c.walks[walk]; known && !w.Open() {
|
||||
// As the controller: only a walk that is building or rolling is stopped (`delivery-stop`).
|
||||
return fmt.Errorf("%s is already %s", walk, w.State)
|
||||
} else if known {
|
||||
w.State = walkFailed
|
||||
if w.Delivery == nil {
|
||||
w.Delivery = &WalkDelivery{}
|
||||
}
|
||||
w.Delivery.Stopped, w.Delivery.StoppedWhy = by, why
|
||||
w.Revision++
|
||||
c.walks[walk] = w
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopWithout ends a walk without the merges named, as the controller does (novox/hq ADR 0299): each merge named,
|
||||
// and each later merge of its repository by merge time (mergedAt), is left out; the others are put in a batch,
|
||||
// "<walk>-again", assembling until a test cuts it. Given a batch, the merges left out go to a stopped record,
|
||||
// "<walk>-left", and the batch goes on with the others. refuseWithout refuses every such stop, as an older
|
||||
// controller does.
|
||||
func (c *fakeController) StopWithout(walk, why, by string, without []string) (StopAnswer, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.down {
|
||||
return StopAnswer{}, errors.New("down")
|
||||
}
|
||||
c.withouts = append(c.withouts, walk+" without "+strings.Join(without, ","))
|
||||
if c.refuseWithout {
|
||||
return StopAnswer{}, errors.New(`delivery-stop takes no argument "without"`)
|
||||
}
|
||||
w, known := c.walks[walk]
|
||||
if !known || !(w.Open() || w.IsBatch()) {
|
||||
return StopAnswer{}, fmt.Errorf("%s is no open walk or batch", walk)
|
||||
}
|
||||
wd := WalkDelivery{}
|
||||
if w.Delivery != nil {
|
||||
wd = *w.Delivery
|
||||
}
|
||||
named := func(m WalkMerge) bool { return containsString(without, m.Repository+"@"+m.Commit) }
|
||||
contains := func(m WalkMerge) string {
|
||||
for _, s := range wd.Merges {
|
||||
if named(s) && s.Repository == m.Repository && c.mergedAt[m.Commit].After(c.mergedAt[s.Commit]) {
|
||||
return s.Commit
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
shares := func(m WalkMerge) (string, []string) {
|
||||
for _, s := range wd.Merges {
|
||||
if named(s) {
|
||||
var both []string
|
||||
for _, x := range c.moves[m.Commit] {
|
||||
if containsString(c.moves[s.Commit], x) {
|
||||
both = append(both, x)
|
||||
}
|
||||
}
|
||||
if len(both) > 0 {
|
||||
return s.Commit, both
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
a := StopAnswer{Stopped: walk}
|
||||
var again, left []WalkMerge
|
||||
for _, m := range wd.Merges {
|
||||
s, both := shares(m)
|
||||
switch {
|
||||
case named(m):
|
||||
a.LeftOut = append(a.LeftOut, WalkLeftOut{Repository: m.Repository, Commit: m.Commit})
|
||||
left = append(left, m)
|
||||
case len(both) > 0:
|
||||
a.LeftOut = append(a.LeftOut, WalkLeftOut{Repository: m.Repository, Commit: m.Commit, Contains: s, Shares: both})
|
||||
left = append(left, m)
|
||||
case contains(m) != "":
|
||||
a.LeftOut = append(a.LeftOut, WalkLeftOut{Repository: m.Repository, Commit: m.Commit, Contains: contains(m)})
|
||||
left = append(left, m)
|
||||
default:
|
||||
m.Carried = ""
|
||||
again = append(again, m)
|
||||
a.WalkedAgain = append(a.WalkedAgain, m.Repository+"@"+m.Commit)
|
||||
}
|
||||
}
|
||||
if len(a.LeftOut) == 0 {
|
||||
return StopAnswer{}, fmt.Errorf("%s answers no merge %s", walk, strings.Join(without, ", "))
|
||||
}
|
||||
if w.IsBatch() && len(again) == 0 {
|
||||
// Every merge left out: the batch itself is stopped, as the controller does.
|
||||
wd.Stopped, wd.StoppedWhy, wd.LeftOut, wd.Batch = by, why, a.LeftOut, nil
|
||||
w.Delivery, w.State = &wd, walkFailed
|
||||
w.Revision++
|
||||
c.walks[walk] = w
|
||||
return a, nil
|
||||
}
|
||||
if w.IsBatch() {
|
||||
rid := walk + "-left"
|
||||
c.walks[rid] = Walk{ID: rid, Repository: left[0].Repository, Branch: "main", Commit: left[0].Commit,
|
||||
Created: w.Created, State: walkFailed, Revision: 1, Modules: map[string]*WalkModule{},
|
||||
Delivery: &WalkDelivery{Stopped: by, StoppedWhy: why, Merges: left, LeftOut: a.LeftOut, WalkedAgainBy: walk}}
|
||||
wd.Merges = again
|
||||
w.Delivery = &wd
|
||||
w.Repository, w.Commit = again[0].Repository, again[0].Commit
|
||||
w.Revision++
|
||||
c.walks[walk] = w
|
||||
a.Stopped, a.WalkedAgainBy = rid, walk
|
||||
return a, nil
|
||||
}
|
||||
if len(again) > 0 {
|
||||
id := walk + "-again"
|
||||
closes := w.Created.Add(time.Minute)
|
||||
c.walks[id] = Walk{ID: id, Repository: again[0].Repository, Branch: "main", Commit: again[0].Commit,
|
||||
Created: w.Created.Add(time.Minute), State: walkAssembling, Revision: 1,
|
||||
Modules: map[string]*WalkModule{}, Delivery: &WalkDelivery{Merges: again, Batch: &WalkBatch{ClosesAt: &closes}}}
|
||||
wd.WalkedAgainBy, a.WalkedAgainBy = id, id
|
||||
}
|
||||
wd.Stopped, wd.StoppedWhy, wd.LeftOut = by, why, a.LeftOut
|
||||
w.Delivery = &wd
|
||||
w.State = walkFailed
|
||||
w.Note = "stopped by " + by + ": " + why
|
||||
w.Revision++
|
||||
c.walks[walk] = w
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// cut makes a batch the walk under the same id, waiting for its word, as the controller cuts it.
|
||||
func (c *fakeController) cut(id string) Walk {
|
||||
c.mu.Lock()
|
||||
w := c.walks[id]
|
||||
c.mu.Unlock()
|
||||
wd := *w.Delivery
|
||||
wd.Batch, wd.Awaits = nil, "mesh-delivery"
|
||||
w.Delivery = &wd
|
||||
w.State, w.Tiers, w.Modules = walkBuilding, [][]string{{"app"}}, map[string]*WalkModule{"app": {}}
|
||||
return c.put(w)
|
||||
}
|
||||
|
||||
func (c *fakeController) Walks(walk string) (bool, []Walk, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.walkReads++
|
||||
if c.down {
|
||||
return false, nil, errors.New("down")
|
||||
}
|
||||
var out []Walk
|
||||
for id, w := range c.walks {
|
||||
if walk == "" || walk == id {
|
||||
out = append(out, w)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return c.held, out, nil
|
||||
}
|
||||
|
||||
// put is the controller keeping a walk; it answers the walk as `plan-moved` would say it.
|
||||
func (c *fakeController) put(w Walk) Walk {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if old, known := c.walks[w.ID]; known {
|
||||
w.Revision = old.Revision + 1
|
||||
} else if w.Revision == 0 {
|
||||
w.Revision = 1
|
||||
}
|
||||
c.walks[w.ID] = w
|
||||
return w
|
||||
}
|
||||
|
||||
func (c *fakeController) walk(id string) Walk {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.walks[id]
|
||||
}
|
||||
|
||||
type fakeForge struct {
|
||||
mu sync.Mutex
|
||||
notes map[string][]string
|
||||
@@ -569,7 +276,6 @@ type world struct {
|
||||
t *testing.T
|
||||
h *Holder
|
||||
store *memStore
|
||||
ctl *fakeController
|
||||
forge *fakeForge
|
||||
op *fakeOperator
|
||||
merges *fakeMerges
|
||||
@@ -584,12 +290,12 @@ type world struct {
|
||||
}
|
||||
|
||||
func newWorld(t *testing.T) *world {
|
||||
return newWorldOver(t, newMemStore(), newFakeController(), newFakeForge(), time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC))
|
||||
return newWorldOver(t, newMemStore(), newFakeForge(), time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC))
|
||||
}
|
||||
|
||||
func newWorldOver(t *testing.T, store *memStore, ctl *fakeController, forge *fakeForge, now time.Time) *world {
|
||||
w := &world{t: t, store: store, ctl: ctl, forge: forge, now: now}
|
||||
w.h = &Holder{Store: store, Controller: ctl, Forge: forge,
|
||||
func newWorldOver(t *testing.T, store *memStore, forge *fakeForge, now time.Time) *world {
|
||||
w := &world{t: t, store: store, forge: forge, now: now}
|
||||
w.h = &Holder{Store: store, Forge: forge,
|
||||
Now: func() time.Time { w.mu.Lock(); defer w.mu.Unlock(); return w.now },
|
||||
Logf: func(format string, a ...any) { t.Logf(format, a...); fmt.Fprintf(&w.logbuf, format+"\n", a...) },
|
||||
Emit: func(event string, body any) error {
|
||||
@@ -667,7 +373,7 @@ func pr(repo string, number int, sha, branch string, paths ...string) PullEvent
|
||||
func verdict(repo string, number int, sha, gate string) CheckedEvent {
|
||||
owner, name, _ := strings.Cut(repo, "/")
|
||||
c := CheckedEvent{Owner: owner, Repo: name, Number: number, Commit: sha, Verdict: gate, Summary: "the gate said " + gate,
|
||||
ID: "check-of-" + sha, Plan: &DeliveryPlan{Repository: repo, Moved: []string{"app"}, Summary: "builds app → anchor"}}
|
||||
ID: "check-of-" + sha}
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -679,16 +385,6 @@ func merged(repo string, number int, head, merge string) PullEvent {
|
||||
return p
|
||||
}
|
||||
|
||||
// aWalk is the controller's walk of a merge commit, waiting for its delivery's word.
|
||||
func aWalk(id, repo, commit string, waits bool) Walk {
|
||||
w := Walk{ID: id, Repository: repo, Branch: "main", Commit: commit, Created: time.Date(2026, 10, 6, 12, 31, 0, 0, time.UTC),
|
||||
State: walkBuilding, Tiers: [][]string{{"app"}}, Modules: map[string]*WalkModule{"app": {}}}
|
||||
if waits {
|
||||
w.Delivery = &WalkDelivery{Awaits: "mesh-delivery"}
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// Comment records what was asked (novox/hq issue 722).
|
||||
func (f *fakeForge) Comment(owner, repo string, number int, body string) error {
|
||||
f.mu.Lock()
|
||||
|
||||
@@ -107,15 +107,3 @@ func TestARefusalLoopInAClosedGroupIsSaidAtOnce(t *testing.T) {
|
||||
// check set its gate (issue 1049: the forge's "not a success: mesh/repo-check (pending)").
|
||||
var ownPending = errors.New("the forge refused gitea_merge_pull_request: novox/mesh-controller#1 is not merged: main requires " +
|
||||
"mesh/merge-gate, mesh/repo-check; not a success: mesh/repo-check (pending). A pull request merges once its checks succeed")
|
||||
|
||||
// ownChecks are the checks asked from index on that were a head's own (no group), not a group's composed check.
|
||||
func ownChecks(w *world, from int) (own, composed []string) {
|
||||
for _, c := range w.ctl.checks[from:] {
|
||||
if strings.HasPrefix(c, ":") {
|
||||
own = append(own, c)
|
||||
} else {
|
||||
composed = append(composed, c)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package main
|
||||
|
||||
import "time"
|
||||
|
||||
var t0 = time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// mergedAt is the forge's `pull.merged` at a moment.
|
||||
func mergedAt(p PullEvent, merge string, at time.Time) PullEvent {
|
||||
p.MergeCommit = merge
|
||||
p.MergedAt = at.UTC().Format(time.RFC3339)
|
||||
return p
|
||||
}
|
||||
|
||||
// verdictMoving is a passing verdict. The gate plans no delivery any more (ADR 0539 §17), so it names no module.
|
||||
func verdictMoving(repo string, number int, sha string, _ ...string) CheckedEvent {
|
||||
return verdict(repo, number, sha, "pass")
|
||||
}
|
||||
|
||||
func statusOn(w *world, repo, commit string) string {
|
||||
w.forge.mu.Lock()
|
||||
defer w.forge.mu.Unlock()
|
||||
return w.forge.statuses[repo+"@"+commit+" mesh/delivery"]
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// History (novox/hq issue 313): merges long past, heard only from the forge's word when this owner first held
|
||||
// the seat, reached the machines before it existed. They are retired — never held for a person, released or
|
||||
// delivered — and what is owed for a pull request the forge no longer holds is given up, not tried for ever.
|
||||
|
||||
// aPastMerge is the forge's `pull.merged` replayed from the bus's history: merged days before it is heard.
|
||||
func aPastMerge(repo string, number int, head, merge string, at time.Time) PullEvent {
|
||||
p := merged(repo, number, head, merge)
|
||||
p.MergedAt = at.UTC().Format(time.RFC3339)
|
||||
return p
|
||||
}
|
||||
|
||||
func TestAMergeLongPastHeardFromTheForgesWordIsRetiredAtAdoption(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", head)
|
||||
w.h.PullMerged(aPastMerge("novox/app", 274, head, merge, w.now.Add(-4*24*time.Hour)))
|
||||
w.settleAll()
|
||||
d := w.delivery(id)
|
||||
if d.State != Retired || !strings.Contains(d.History, "history") {
|
||||
t.Fatalf("a merge four days past heard from the forge's word is %s (%q)", d.State, d.History)
|
||||
}
|
||||
if got := statesOf([]State{d.Transitions[0].To, d.Transitions[1].To}); strings.Join(got, ",") != "proposed,retired" ||
|
||||
d.Transitions[1].Event != EvHistory {
|
||||
t.Fatalf("transitions %+v", d.Transitions)
|
||||
}
|
||||
// Nothing is said on its pull request — the forge may hold another under its number — and nothing stalls.
|
||||
if w.forge.viewCalls != 0 || len(w.forge.statuses) != 0 {
|
||||
t.Fatalf("history was shown on a pull request: %d view(s), statuses %v", w.forge.viewCalls, w.forge.statuses)
|
||||
}
|
||||
if len(d.Owed) != 0 {
|
||||
t.Fatalf("still owed: %+v", d.Owed)
|
||||
}
|
||||
if s := w.h.Stalled(); len(s) != 0 {
|
||||
t.Fatalf("history stalls: %+v", s)
|
||||
}
|
||||
// Its record is still kept, on the commit's note.
|
||||
if len(w.forge.notesOn(head)) == 0 {
|
||||
t.Fatal("no note of it on its commit")
|
||||
}
|
||||
|
||||
// A merge heard from the forge's word within moments of happening is a live one: held for a person.
|
||||
live := IDOf("novox/app", "bbbbbbbbbbbb2222")
|
||||
w.h.PullMerged(aPastMerge("novox/app", 9, "bbbbbbbbbbbb2222", "cccccccccccc3333", w.now.Add(-time.Minute)))
|
||||
if s := w.state(live); s != Held {
|
||||
t.Fatalf("a live merge from the forge's word is %s", s)
|
||||
}
|
||||
// A merge long past whose walk the controller still holds open is delivered by it, never history.
|
||||
walked := IDOf("novox/app", "dddddddddddd4444")
|
||||
w.h.WalkMoved(aWalk("plan-9", "novox/app", "eeeeeeeeeeee5555", true))
|
||||
w.h.PullMerged(aPastMerge("novox/app", 10, "dddddddddddd4444", "eeeeeeeeeeee5555", w.now.Add(-48*time.Hour)))
|
||||
if s := w.state(walked); s == Retired {
|
||||
t.Fatal("a merge with an open walk was retired as history")
|
||||
}
|
||||
// A merge the forge announces under a number an older delivery already merged with is not that delivery.
|
||||
w.h.PullMerged(aPastMerge("novox/app", 274, "", "ffffffffffff6666", w.now.Add(-time.Minute)))
|
||||
if d := w.delivery(id); d.MergedAs != merge {
|
||||
t.Fatalf("a reused number merged the old delivery again as %s", d.MergedAs)
|
||||
}
|
||||
}
|
||||
|
||||
// oldHistory is a held delivery as the first adoption left it: made from the forge's word, held, owing a view
|
||||
// on a pull request the forge no longer holds.
|
||||
func oldHistory(id, repo string, number int, created, mergedAt time.Time) *Delivery {
|
||||
commit := strings.SplitN(id, "@", 2)[1] + "0000"
|
||||
return &Delivery{ID: id, Repository: repo, Commit: commit, Number: number, Base: "main", MergedAs: commit,
|
||||
MergedAt: mergedAt, Created: created, Since: created, State: Held,
|
||||
HeldWhy: "merged without a passing check: only a person decides that it goes on",
|
||||
Transitions: []Transition{
|
||||
{At: created, To: Proposed, Event: EvAnnounced, Why: repo + "#274 merged; its head was not heard by this " +
|
||||
"owner, so it is made from the forge's word"},
|
||||
{At: created, From: Proposed, To: Held, Event: EvMergedUnchecked, Why: "merged without a passing check"}},
|
||||
Owed: []Effect{{Kind: EffectView, Since: created, Tries: 11051,
|
||||
Last: "Gitea API /repos/" + repo + "/issues/274/comments: 500 issue does not exist"}}}
|
||||
}
|
||||
|
||||
func TestRetireHistoryRetiresTheAdoptedHistoryInBulkAndLeavesEveryOtherHeldOne(t *testing.T) {
|
||||
store := newMemStore()
|
||||
start := time.Date(2026, 10, 6, 23, 5, 27, 0, time.UTC)
|
||||
for _, d := range []*Delivery{
|
||||
oldHistory("novox/hq@018ee359ae52", "novox/hq", 274, start, start.Add(-4*24*time.Hour)),
|
||||
oldHistory("novox/lab@a4f6bb8ce054", "novox/lab", 53, start, start.Add(-2*time.Hour)),
|
||||
// Made from the forge's word, but heard a minute after its merge: a live merge, a person's.
|
||||
oldHistory("novox/hq@7777777777aa", "novox/hq", 3, start, start.Add(-time.Minute)),
|
||||
} {
|
||||
if err := store.PutDelivery(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
w := newWorldOver(t, store, newFakeController(), newFakeForge(), start.Add(36*time.Hour))
|
||||
// And one heard live: rejected, then merged — a person's to decide.
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.h.Checked(verdict("novox/app", 7, head, "fail"))
|
||||
w.h.PullMerged(merged("novox/app", 7, head, merge))
|
||||
if len(w.h.Stalled()) != 3 { // the three adopted at the switch; the one held just now is within its bound
|
||||
t.Fatalf("stalled before: %+v", w.h.Stalled())
|
||||
}
|
||||
|
||||
dry, err := w.h.RetireHistory("", "", "history adopted at the switch", aPerson, true)
|
||||
if err != nil || dry.Retired != 2 || dry.LeftHeld != 2 || len(dry.Left) != 2 {
|
||||
t.Fatalf("a dry retire: %+v %v", dry, err)
|
||||
}
|
||||
if s := w.state("novox/hq@018ee359ae52"); s != Held {
|
||||
t.Fatalf("a dry retire moved a delivery to %s", s)
|
||||
}
|
||||
if _, err := w.h.RetireHistory("", "", "", aPerson, false); err == nil {
|
||||
t.Fatal("a retire with no why was taken")
|
||||
}
|
||||
|
||||
got, err := w.h.RetireHistory("", "", "history adopted at the switch", aPerson, false)
|
||||
if err != nil || got.Retired != 2 || got.ByRepository["novox/hq"] != 1 || got.ByRepository["novox/lab"] != 1 {
|
||||
t.Fatalf("retire: %+v %v", got, err)
|
||||
}
|
||||
for _, id := range []string{"novox/hq@018ee359ae52", "novox/lab@a4f6bb8ce054"} {
|
||||
d := w.delivery(id)
|
||||
if d.State != Retired || d.Transitions[len(d.Transitions)-1].By != aPerson {
|
||||
t.Fatalf("%s is %s", id, d.State)
|
||||
}
|
||||
for _, e := range d.Owed {
|
||||
if e.Kind == EffectView || e.Kind == EffectStatus {
|
||||
t.Fatalf("%s still owes its pull request: %+v", id, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, id := range []string{"novox/hq@7777777777aa", IDOf("novox/app", head)} {
|
||||
if s := w.state(id); s != Held {
|
||||
t.Fatalf("%s, not history, is %s", id, s)
|
||||
}
|
||||
}
|
||||
// Nothing was released, delivered or let go.
|
||||
if len(w.ctl.delivers) != 0 || len(w.ctl.stops) != 0 {
|
||||
t.Fatalf("a retire asked the controller: deliver %v stop %v", w.ctl.delivers, w.ctl.stops)
|
||||
}
|
||||
if s := w.h.Stalled(); len(s) != 1 || s[0].ID != "novox/hq@7777777777aa" {
|
||||
t.Fatalf("stalled after: %+v", s)
|
||||
}
|
||||
// Retired is final: a second retire takes nothing, and nothing leaves it.
|
||||
again, _ := w.h.RetireHistory("", "", "again", aPerson, false)
|
||||
if again.Retired != 0 {
|
||||
t.Fatalf("retired twice: %+v", again)
|
||||
}
|
||||
if _, err := w.h.Release("novox/hq@018ee359ae52", "go on", aPerson); err == nil {
|
||||
t.Fatal("a retired delivery was released")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetireHistoryIsRefusedForARealHeldDelivery(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", head)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.h.Checked(verdict("novox/app", 7, head, "fail"))
|
||||
w.h.PullMerged(merged("novox/app", 7, head, merge))
|
||||
w.later(48 * time.Hour)
|
||||
_, err := w.h.RetireHistory(id, "", "looks old", aPerson, false)
|
||||
var refused ErrRefused
|
||||
if !errors.As(err, &refused) || !strings.Contains(err.Error(), "heard its head") {
|
||||
t.Fatalf("a held delivery whose head was heard was retired: %v", err)
|
||||
}
|
||||
if s := w.state(id); s != Held {
|
||||
t.Fatalf("a refused retire left it %s", s)
|
||||
}
|
||||
// A delivery that is not held is refused too, by the table.
|
||||
other := IDOf("novox/app", "bbbbbbbbbbbb2222")
|
||||
w.h.PullUpdated(pr("novox/app", 8, "bbbbbbbbbbbb2222", "feat/y", "src/b.go"))
|
||||
if _, err := w.h.RetireHistory(other, "", "old", aPerson, false); err == nil || w.state(other) != Proposed {
|
||||
t.Fatalf("a proposed delivery was retired: %v", err)
|
||||
}
|
||||
if _, err := w.h.RetireHistory("novox/app@nothing", "", "old", aPerson, false); err == nil {
|
||||
t.Fatal("retired a delivery that does not exist")
|
||||
}
|
||||
}
|
||||
|
||||
// What the bus cannot take is owed and tried again later each time, and given up after retryLimit tries, saying so. What the
|
||||
// forge refuses is the bridge's to say (issue 1349): the gate has no retry for it.
|
||||
func TestOwedWorkTheBusCannotTakeIsTriedAgainLaterAndGivenUpAfterRetryLimit(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", head)
|
||||
w.owed.down = true
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
tries := func() int {
|
||||
for _, e := range w.delivery(id).Owed {
|
||||
if e.Kind == EffectView {
|
||||
return e.Tries
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
w.h.Flush()
|
||||
w.h.Flush()
|
||||
w.h.Flush()
|
||||
if tries() != 1 {
|
||||
t.Fatalf("a view the bus could not take was tried %d time(s) at once", tries())
|
||||
}
|
||||
w.later(retryFirst + time.Second)
|
||||
w.h.Flush()
|
||||
if tries() != 2 {
|
||||
t.Fatalf("a failed view was not tried again after its wait: %d", tries())
|
||||
}
|
||||
if retryAfter(1) != retryFirst || retryAfter(2) != 2*retryFirst || retryAfter(100) != retryCap {
|
||||
t.Fatalf("the waits: %s %s %s", retryAfter(1), retryAfter(2), retryAfter(100))
|
||||
}
|
||||
for range retryLimit {
|
||||
w.later(retryCap)
|
||||
w.h.Flush()
|
||||
}
|
||||
if tries() != -1 {
|
||||
t.Fatalf("a view failing %d times is still owed", tries())
|
||||
}
|
||||
if st := w.h.Status("listening"); !strings.Contains(strings.Join(st["refused"].([]string), "\n"), "given up") {
|
||||
t.Fatalf("its drop was not said: %v", st["refused"])
|
||||
}
|
||||
// The bus back: what is owed anew is written.
|
||||
w.owed.down = false
|
||||
w.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
w.h.Flush()
|
||||
if len(w.owed.acts(sdk.OwedView)) == 0 {
|
||||
t.Fatal("nothing was written once the bus was back")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,17 +7,16 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// The holder, end to end over fakes (novox/hq ADR 0239 *How it is checked*): a pull request's head proposed,
|
||||
// checked and ready; merged, published, let go, delivering and delivered; a gate that fails, put back and
|
||||
// failed; a newer head superseding; a restart resuming; the trunk rule; and every transition kept before
|
||||
// it is said, noted on the commit and shown on the pull request.
|
||||
// The holder, end to end over fakes (novox/hq ADR 0239 *How it is checked*, cut by ADR 0539 §17): a pull request's head
|
||||
// proposed and ready; merged; a newer head closing the older; a restart resuming; the trunk rule; and every transition
|
||||
// kept before it is said, noted on the commit and shown on the pull request.
|
||||
|
||||
const (
|
||||
head = "aaaaaaaaaaaa1111"
|
||||
merge = "mmmmmmmmmmmm9999"
|
||||
)
|
||||
|
||||
func TestAPullRequestsHeadIsProposedCheckedAndReady(t *testing.T) {
|
||||
func TestAPullRequestsHeadIsProposedAndReady(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
p := pr("novox/app", 7, head, "feat/x", "src/a.go")
|
||||
p.Body = "" // an empty description is never read again, so the review set below stays
|
||||
@@ -30,19 +29,19 @@ func TestAPullRequestsHeadIsProposedCheckedAndReady(t *testing.T) {
|
||||
}
|
||||
w.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
d := w.delivery(id)
|
||||
if d.State != Ready || d.Plan == nil || d.Plan.Summary != "builds app → anchor" {
|
||||
t.Fatalf("a passing verdict left it %s with plan %+v", d.State, d.Plan)
|
||||
if d.State != Ready {
|
||||
t.Fatalf("a passing verdict left it %s", d.State)
|
||||
}
|
||||
var path []State
|
||||
for _, tr := range d.Transitions {
|
||||
path = append(path, tr.To)
|
||||
}
|
||||
if strings.Join(statesOf(path), ",") != "proposed,checked,ready" {
|
||||
if strings.Join(statesOf(path), ",") != "proposed,ready" {
|
||||
t.Fatalf("its transitions are %v", path)
|
||||
}
|
||||
w.settleAll()
|
||||
notes := w.forge.notesOn(head)
|
||||
if len(notes) != 3 || !strings.Contains(notes[2], "checked -> ready") {
|
||||
if len(notes) != 2 || !strings.Contains(notes[1], "proposed -> ready") {
|
||||
t.Fatalf("the head's note is %q", notes)
|
||||
}
|
||||
if !strings.Contains(w.forge.statuses["novox/app@"+head+" mesh/delivery"], "success ready") {
|
||||
@@ -51,9 +50,9 @@ func TestAPullRequestsHeadIsProposedCheckedAndReady(t *testing.T) {
|
||||
if v := w.forge.views["novox/app#7"]; !strings.Contains(v, viewMarker) || !strings.Contains(v, "**ready**") {
|
||||
t.Fatalf("the pull request's view is %q", v)
|
||||
}
|
||||
// A failing verdict for another head rejects it, and a newer head supersedes the older.
|
||||
// A failing verdict for another head rejects it, and a newer head closes the older.
|
||||
w.h.PullUpdated(pr("novox/app", 7, "bbbbbbbbbbbb2222", "feat/x"))
|
||||
if w.state(id) != Superseded {
|
||||
if w.state(id) != Closed {
|
||||
t.Fatalf("a newer head left the older %s", w.state(id))
|
||||
}
|
||||
w.h.Checked(verdict("novox/app", 7, "bbbbbbbbbbbb2222", "fail"))
|
||||
@@ -70,58 +69,32 @@ func statesOf(ss []State) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// A merge publishes; the walk waits for this owner's word; the word is given; the walk's record moves the
|
||||
// delivery through its machines to delivered — and the note on the merge commit says what was executed.
|
||||
func TestAMergeIsPublishedLetGoDeliveringAndDelivered(t *testing.T) {
|
||||
// A merge puts the delivery on the trunk and ends it there: merged, with the merge commit's note and the pull request's
|
||||
// status naming the source revision it moved. Nothing after the merge is derived (ADR 0539 §17).
|
||||
func TestAMergeIsMergedAndTheGateDerivesNothingAfterIt(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", head)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
// The walk is heard before the merge: it waits for its delivery.
|
||||
walk := w.ctl.put(aWalk("plan-1", "novox/app", merge, true))
|
||||
w.h.WalkMoved(walk)
|
||||
if w.state(id) != Ready {
|
||||
t.Fatalf("a walk for a commit no merge was heard of moved the delivery to %s", w.state(id))
|
||||
}
|
||||
w.h.PullMerged(merged("novox/app", 7, head, merge))
|
||||
if w.state(id) != Published {
|
||||
t.Fatalf("a merge with its walk open left it %s", w.state(id))
|
||||
}
|
||||
w.settleAll()
|
||||
if len(w.ctl.delivers) != 1 || w.ctl.delivers[0] != "plan-1" {
|
||||
t.Fatalf("its walk was let go %v", w.ctl.delivers)
|
||||
}
|
||||
if w.state(id) != Delivering {
|
||||
t.Fatalf("let go, it is %s", w.state(id))
|
||||
}
|
||||
// The first machine sent, judged, passed; the rest sent; the walk done.
|
||||
sent := time.Date(2026, 10, 6, 12, 40, 0, 0, time.UTC)
|
||||
walk = w.ctl.walk("plan-1")
|
||||
walk.State = walkRolling
|
||||
walk.Modules["app"] = &WalkModule{State: "built", First: []string{"anchor"}, FirstAt: &sent, Build: "build-1",
|
||||
Gate: &WalkGate{Machines: []string{"anchor"}, Since: &sent, Passes: 1}}
|
||||
w.h.WalkMoved(w.ctl.put(walk))
|
||||
if d := w.delivery(id); len(d.Steps) != 1 || d.Steps[0].State != StepJudging {
|
||||
t.Fatalf("judging on its first machine reads %+v", d.Steps)
|
||||
}
|
||||
judged := sent.Add(3 * time.Minute)
|
||||
walk.Modules["app"].Gate.Verdict, walk.Modules["app"].Gate.JudgedAt = "passed", &judged
|
||||
walk.Modules["app"].SentAt = &judged
|
||||
walk.State = walkDone
|
||||
w.h.WalkMoved(w.ctl.put(walk))
|
||||
d := w.delivery(id)
|
||||
if d.State != Delivered {
|
||||
t.Fatalf("a walk done left it %s", d.State)
|
||||
}
|
||||
if len(d.Steps) != 2 || d.Steps[0].State != StepPassed || d.Steps[1].Machine != "the rest running it" {
|
||||
t.Fatalf("its machine steps are %+v", d.Steps)
|
||||
if d.State != Merged || d.MergedAs != merge {
|
||||
t.Fatalf("a merge left it %s as %q", d.State, d.MergedAs)
|
||||
}
|
||||
w.settleAll()
|
||||
onMerge := w.forge.notesOn(merge)
|
||||
if len(onMerge) == 0 || !strings.Contains(strings.Join(onMerge, "\n"), "executed (walk plan-1): app on anchor passed") {
|
||||
t.Fatalf("the merge commit's note is %q", onMerge)
|
||||
w.later(72 * time.Hour)
|
||||
w.settleAll()
|
||||
if s := w.state(id); s != Merged {
|
||||
t.Fatalf("time moved a merged delivery to %s", s)
|
||||
}
|
||||
if st := w.forge.statuses["novox/app@"+head+" mesh/delivery"]; !strings.HasPrefix(st, "success merged: source.main moved to novox/app@"+merge[:12]) {
|
||||
t.Fatalf("the pull request's mesh/delivery status is %q", st)
|
||||
}
|
||||
view := w.forge.views["novox/app#7"]
|
||||
if !strings.Contains(view, "**Source**") || !strings.Contains(view, "source.main moved to novox/app@"+merge[:12]) ||
|
||||
!strings.Contains(view, "mesh-delivery-view") || strings.Contains(view, "**Delivery plan**") || strings.Contains(view, "**Machines**") {
|
||||
t.Fatalf("the pull request's view is %q", view)
|
||||
}
|
||||
// Said on the bus, kept first: every transition emitted once.
|
||||
n := 0
|
||||
for _, s := range w.said {
|
||||
if s["id"] == id {
|
||||
@@ -133,110 +106,52 @@ func TestAMergeIsPublishedLetGoDeliveringAndDelivered(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A first machine's gate fails: what it carried is put back (the controller's), the step is rolled back,
|
||||
// and the delivery failed — never delivered.
|
||||
func TestAFailedGateIsRolledBackAndTheDeliveryFails(t *testing.T) {
|
||||
// The trunk rule: a ready delivery whose commit never reached the trunk is never merged.
|
||||
func TestACommitOffTheTrunkIsNeverMerged(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", head)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
w.h.PullMerged(merged("novox/app", 7, head, merge))
|
||||
w.h.WalkMoved(w.ctl.put(aWalk("plan-1", "novox/app", merge, true)))
|
||||
w.settleAll()
|
||||
sent := time.Date(2026, 10, 6, 12, 40, 0, 0, time.UTC)
|
||||
walk := w.ctl.walk("plan-1")
|
||||
walk.State = walkRolling
|
||||
walk.Modules["app"] = &WalkModule{State: "built", First: []string{"anchor"}, FirstAt: &sent,
|
||||
Gate: &WalkGate{Machines: []string{"anchor"}, Since: &sent}}
|
||||
w.h.WalkMoved(w.ctl.put(walk))
|
||||
at := sent.Add(10 * time.Minute)
|
||||
walk.Modules["app"].Gate.Verdict, walk.Modules["app"].Gate.Why, walk.Modules["app"].Gate.JudgedAt =
|
||||
"failed", "its tools were not served", &at
|
||||
walk.Modules["app"].Gate.Rollback = "rolled-back"
|
||||
walk.State = walkFailed
|
||||
walk.Note = "app failed its gate on anchor"
|
||||
w.h.WalkMoved(w.ctl.put(walk))
|
||||
d := w.delivery(id)
|
||||
if d.State != Failed || len(d.Steps) != 1 || d.Steps[0].State != StepRolledBack {
|
||||
t.Fatalf("a failed gate left it %s, steps %+v", d.State, d.Steps)
|
||||
}
|
||||
// An older record arriving late changes nothing; a step the table does not hold is refused.
|
||||
w.h.WalkMoved(aWalk("plan-1", "novox/app", merge, false))
|
||||
if w.state(id) != Failed {
|
||||
t.Fatal("an older record of the walk moved a final delivery")
|
||||
}
|
||||
}
|
||||
|
||||
// A walk superseded that names no walk taking it over (a record from before ADR 0276): superseded, from
|
||||
// published or delivering. One that names its successor is followed (TestADeliveryFollowsTheWalkThatTookItsOver).
|
||||
func TestANewerWalkSupersedesAnOlderDelivery(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", head)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
w.h.PullMerged(merged("novox/app", 7, head, merge))
|
||||
walk := aWalk("plan-1", "novox/app", merge, true)
|
||||
w.h.WalkMoved(w.ctl.put(walk))
|
||||
walk.State = walkSuperseded
|
||||
walk.Note = "superseded"
|
||||
w.ctl.down = true // its word could not be given meanwhile
|
||||
w.settleAll()
|
||||
w.h.WalkMoved(w.ctl.put(walk))
|
||||
if s := w.state(id); s != Superseded {
|
||||
t.Fatalf("its walk superseded left it %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// The trunk rule: a ready delivery whose commit never reached the trunk is never published, whatever walks
|
||||
// are heard of; and a walk for a commit no delivery landed as is not taken as its.
|
||||
func TestACommitOffTheTrunkIsNeverPublished(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", head)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
w.h.WalkMoved(w.ctl.put(aWalk("plan-9", "novox/app", head, true))) // a walk of the head itself: no merge
|
||||
w.settleAll()
|
||||
if s := w.state(id); s != Ready {
|
||||
t.Fatalf("an off-trunk head is %s", s)
|
||||
}
|
||||
if len(w.ctl.delivers) != 0 {
|
||||
t.Fatalf("a walk was let go for a commit off the trunk: %v", w.ctl.delivers)
|
||||
}
|
||||
}
|
||||
|
||||
// A restart resumes: a new holder over the same state stands every delivery where it was, reads the walks it
|
||||
// missed back from the controller, and goes on.
|
||||
// A restart resumes: a new holder over the same state stands every delivery where it was.
|
||||
func TestARestartResumesFromTheState(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", head)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
w.h.PullMerged(merged("novox/app", 7, head, merge))
|
||||
w.ctl.put(aWalk("plan-1", "novox/app", merge, true))
|
||||
if err := w.h.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.settleAll()
|
||||
if s := w.state(id); s != Delivering {
|
||||
if s := w.state(id); s != Merged {
|
||||
t.Fatalf("before the restart it is %s", s)
|
||||
}
|
||||
// While it is down, the walk finishes and nobody says so.
|
||||
walk := w.ctl.walk("plan-1")
|
||||
walk.State = walkDone
|
||||
w.ctl.put(walk)
|
||||
|
||||
again := newWorldOver(t, w.store, w.ctl, w.forge, w.now.Add(time.Hour))
|
||||
if s := again.state(id); s != Delivering {
|
||||
again := newWorldOver(t, w.store, w.forge, w.now.Add(time.Hour))
|
||||
if s := again.state(id); s != Merged {
|
||||
t.Fatalf("read back, it is %s", s)
|
||||
}
|
||||
if err := again.h.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A delivery kept in a state an earlier owner had and this one does not is read as the state it came to and kept so.
|
||||
func TestADeliveryKeptInAGoneStateIsReadAsTheMergeStateItCameTo(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", head)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
w.h.PullMerged(merged("novox/app", 7, head, merge))
|
||||
w.settleAll()
|
||||
raw := w.store.deliveries[id]
|
||||
w.store.deliveries[id] = []byte(strings.Replace(string(raw), `"state":"merged"`, `"state":"delivering"`, 1))
|
||||
again := newWorldOver(t, w.store, w.forge, w.now.Add(time.Hour))
|
||||
if s := again.state(id); s != Merged {
|
||||
t.Fatalf("a delivering delivery was read as %s", s)
|
||||
}
|
||||
if s := again.state(id); s != Delivered {
|
||||
t.Fatalf("the walk it missed was not read back: %s", s)
|
||||
}
|
||||
if len(w.ctl.delivers) != 1 {
|
||||
t.Fatalf("a restarted holder let the walk go again: %v", w.ctl.delivers)
|
||||
again.settleAll()
|
||||
if !strings.Contains(string(w.store.deliveries[id]), `"state":"merged"`) {
|
||||
t.Fatalf("the read state was not kept: %s", w.store.deliveries[id])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,7 +180,7 @@ func TestATransitionIsKeptBeforeItIsSaid(t *testing.T) {
|
||||
w.later(time.Hour) // each failure put its next try later: past every one of them
|
||||
w.settleAll()
|
||||
w.settleAll()
|
||||
if notes := w.forge.notesOn(head); len(notes) != 3 {
|
||||
if notes := w.forge.notesOn(head); len(notes) != 2 {
|
||||
t.Fatalf("after the bus came back the note has %d line(s): %q", len(notes), notes)
|
||||
}
|
||||
if d := w.delivery(IDOf("novox/app", head)); len(d.Owed) != 0 {
|
||||
@@ -273,66 +188,8 @@ func TestATransitionIsKeptBeforeItIsSaid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// At the switch: a walk already running is adopted as a delivery that is delivering; a waiting walk whose pull
|
||||
// request this owner never heard of waits for a person, and a person's release lets it go.
|
||||
func TestTheSwitchAdoptsRunningWalksAndHoldsUnknownWaitingOnes(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
running := aWalk("plan-old", "novox/mesh-catalog", "cccccccccccc3333", false)
|
||||
running.Created = w.now.Add(-time.Hour)
|
||||
running.State = walkRolling
|
||||
w.ctl.put(running)
|
||||
waiting := aWalk("plan-new", "novox/app", "dddddddddddd4444", true)
|
||||
waiting.Created = w.now.Add(time.Minute)
|
||||
w.ctl.put(waiting)
|
||||
if err := w.h.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.h.React()
|
||||
if s := w.state(IDOf("novox/mesh-catalog", "cccccccccccc3333")); s != Delivering {
|
||||
t.Fatalf("a running walk at the switch was adopted as %s", s)
|
||||
}
|
||||
if _, made := w.h.deliveries[IDOf("novox/app", "dddddddddddd4444")]; made {
|
||||
t.Fatal("a new walk was made a delivery before its merge had time to be heard")
|
||||
}
|
||||
w.later(3 * time.Minute)
|
||||
w.h.React()
|
||||
held := IDOf("novox/app", "dddddddddddd4444")
|
||||
if s := w.state(held); s != Held {
|
||||
t.Fatalf("a waiting walk with no pull request is %s", s)
|
||||
}
|
||||
if _, err := w.h.Release(held, "", "jochen"); err == nil {
|
||||
t.Fatal("released without why")
|
||||
}
|
||||
if _, err := w.h.Release(held, "checked by hand", "jochen"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := w.state(held); s != Delivering || len(w.ctl.delivers) != 1 {
|
||||
t.Fatalf("released, it is %s and let go %v", s, w.ctl.delivers)
|
||||
}
|
||||
}
|
||||
|
||||
// mesh-delivery's controller down: nothing is lost — its word is asked again on the next tick.
|
||||
func TestAControllerThatDoesNotAnswerIsAskedAgain(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", head)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
w.h.PullMerged(merged("novox/app", 7, head, merge))
|
||||
w.h.WalkMoved(w.ctl.put(aWalk("plan-1", "novox/app", merge, true)))
|
||||
w.ctl.down = true
|
||||
w.settleAll()
|
||||
if s := w.state(id); s != Published {
|
||||
t.Fatalf("with the controller down it is %s", s)
|
||||
}
|
||||
w.ctl.down = false
|
||||
w.settleAll()
|
||||
if s := w.state(id); s != Delivering {
|
||||
t.Fatalf("with the controller back it is %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// A merge before the check passed is held for a person; a merge this owner never heard the head of is made
|
||||
// from the forge's word and published when the forge says its gate passed.
|
||||
// from the forge's word and merged when the forge says its gate passed.
|
||||
func TestAMergeWithoutAPassingCheckIsHeld(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
@@ -348,50 +205,38 @@ func TestAMergeWithoutAPassingCheckIsHeld(t *testing.T) {
|
||||
w.h.PullMerged(m)
|
||||
w.settleAll()
|
||||
d := w.delivery(IDOf("novox/lab", "eeeeeeeeeeee5555"))
|
||||
// The fake planner says it moves app; with no walk yet it waits published.
|
||||
if d.State != Ready && d.State != Published {
|
||||
if d.State != Merged {
|
||||
t.Fatalf("a merge whose head passed by the forge's word is %s", d.State)
|
||||
}
|
||||
}
|
||||
|
||||
// A member that fails stops no other (ADR 0418 §5): the plan that fails fails as every plan does, and what was delivered
|
||||
// stays. Nothing is stopped member by member.
|
||||
func TestAFailedMemberStopsNoOtherMember(t *testing.T) {
|
||||
// A group merged whole ends merged, member by member and as a group, and source.main holds its heads (ADR 0539 §17, R6-C2).
|
||||
func TestAMergedGroupEndsMergedWithSourceMainMoved(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
heads := map[string]string{"novox/a": "a1a1a1a1a1a1a1", "novox/b": "b2b2b2b2b2b2b2", "novox/c": "c3c3c3c3c3c3c3"}
|
||||
heads := map[string]string{"novox/a": "a1a1a1a1a1a1a1", "novox/b": "b2b2b2b2b2b2b2"}
|
||||
ids := map[string]string{}
|
||||
for i, repo := range []string{"novox/a", "novox/b", "novox/c"} {
|
||||
for i, repo := range []string{"novox/a", "novox/b"} {
|
||||
ids[repo] = IDOf(repo, heads[repo])
|
||||
w.h.PullUpdated(pr(repo, i+1, heads[repo], "feat/y", "x"))
|
||||
w.h.Checked(verdict(repo, i+1, heads[repo], "pass"))
|
||||
}
|
||||
w.settleAll()
|
||||
w.h.Checked(CheckedEvent{Group: "feat/y", ID: "check-1", Verdict: "pass", Summary: "compose"})
|
||||
for i, repo := range []string{"novox/a", "novox/b", "novox/c"} {
|
||||
mergeCommit := strings.Repeat(string(rune('p'+i)), 12)
|
||||
w.h.PullMerged(merged(repo, i+1, heads[repo], mergeCommit))
|
||||
w.h.WalkMoved(w.ctl.put(aWalk("plan-"+repo[6:], repo, mergeCommit, true)))
|
||||
for i, repo := range []string{"novox/a", "novox/b"} {
|
||||
w.h.PullMerged(merged(repo, i+1, heads[repo], strings.Repeat(string(rune('p'+i)), 12)))
|
||||
}
|
||||
w.settleAll()
|
||||
if len(w.ctl.delivers) != 3 {
|
||||
t.Fatalf("every member merged, yet the walks were let go %v", w.ctl.delivers)
|
||||
for repo, id := range ids {
|
||||
if s := w.state(id); s != Merged {
|
||||
t.Errorf("%s is %s, wanted merged", repo, s)
|
||||
}
|
||||
}
|
||||
a := w.ctl.walk("plan-a")
|
||||
a.State = walkDone
|
||||
w.h.WalkMoved(w.ctl.put(a))
|
||||
b := w.ctl.walk("plan-b")
|
||||
b.State = walkFailed
|
||||
b.Note = "b failed its gate on anchor"
|
||||
w.h.WalkMoved(w.ctl.put(b))
|
||||
w.settleAll()
|
||||
if w.state(ids["novox/a"]) != Delivered || w.state(ids["novox/b"]) != Failed || w.state(ids["novox/c"]) == Stopped {
|
||||
t.Fatalf("a %s, b %s, c %s", w.state(ids["novox/a"]), w.state(ids["novox/b"]), w.state(ids["novox/c"]))
|
||||
g := w.h.groups["feat/y"]
|
||||
if state, why := GroupState(g, w.h.membersOf(g)); state != "merged" {
|
||||
t.Fatalf("the group is %s (%s), wanted merged", state, why)
|
||||
}
|
||||
if len(w.ctl.stops) != 0 || len(w.ctl.withouts) != 0 {
|
||||
t.Fatalf("a walk was stopped because another failed: %v %v", w.ctl.stops, w.ctl.withouts)
|
||||
}
|
||||
if state, _ := GroupState(w.h.groups["feat/y"], w.h.membersOf(w.h.groups["feat/y"])); state != "failed" {
|
||||
t.Fatalf("the group is %s", state)
|
||||
if w.source.main == nil || w.source.main.Heads["novox/a"] != strings.Repeat("p", 12) || w.source.main.Heads["novox/b"] != strings.Repeat("q", 12) {
|
||||
t.Fatalf("source.main is %+v, wanted both merges", w.source.main)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,44 +279,38 @@ func TestAnAfterLineIsNotReadAndSaidOnceAndNothingWaitsForAnOrder(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// Stalled: a delivery past its state's bound is listed with what H2 may do; close takes only that.
|
||||
// Stalled: a delivery past its state's bound is listed with what H2 may do; close takes only that. Only a proposed
|
||||
// delivery has a bound now: its check was asked and not answered.
|
||||
func TestStalledAndCloseWorkFromTheTable(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", head)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
w.h.PullMerged(merged("novox/app", 7, head, merge))
|
||||
walk := w.ctl.put(aWalk("plan-1", "novox/app", merge, false))
|
||||
w.h.WalkMoved(walk)
|
||||
if w.state(id) != Delivering {
|
||||
t.Fatalf("it is %s", w.state(id))
|
||||
}
|
||||
if _, err := w.h.Close(id, "too early"); err == nil {
|
||||
t.Fatal("closed within its bound")
|
||||
}
|
||||
w.later(3 * time.Hour)
|
||||
stalled := w.h.Stalled()
|
||||
if len(stalled) != 1 || stalled[0].ID != id || !strings.Contains(stalled[0].H2, "close") {
|
||||
if len(stalled) != 1 || stalled[0].ID != id || stalled[0].State != Proposed {
|
||||
t.Fatalf("stalled %+v", stalled)
|
||||
}
|
||||
if _, err := w.h.Close(id, "delivery stalled"); err == nil {
|
||||
t.Fatal("closed with nothing in the walk's record saying it moved")
|
||||
// A merge is the gate's last word: it is never stalled, however old.
|
||||
w.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
w.h.PullMerged(merged("novox/app", 7, head, merge))
|
||||
w.later(72 * time.Hour)
|
||||
for _, s := range w.h.Stalled() {
|
||||
if s.ID == id {
|
||||
t.Fatalf("a merged delivery is stalled: %+v", s)
|
||||
}
|
||||
}
|
||||
walk.State = walkDone
|
||||
w.ctl.put(walk) // finished, and the event was missed
|
||||
said, err := w.h.Close(id, "delivery stalled")
|
||||
if err != nil || w.state(id) != Delivered {
|
||||
t.Fatalf("H2's close left it %s: %v", w.state(id), err)
|
||||
if _, err := w.h.Close(id, "x"); err == nil {
|
||||
t.Fatal("H2 closed a merged delivery")
|
||||
}
|
||||
if !strings.Contains(said, "delivered") {
|
||||
t.Fatalf("close says %q", said)
|
||||
}
|
||||
// Held is the operator's: H2 has no transition there.
|
||||
// A merge without a passing check is held: a person's, with no transition for H2.
|
||||
w.h.PullUpdated(pr("novox/app", 8, "ffffffffffff0000", "feat/q"))
|
||||
w.h.PullMerged(merged("novox/app", 8, "ffffffffffff0000", "eeeeeeeeeeee0000"))
|
||||
w.later(48 * time.Hour)
|
||||
if _, err := w.h.Close(IDOf("novox/app", "ffffffffffff0000"), "x"); err == nil || !strings.Contains(err.Error(), "operator's") {
|
||||
t.Fatalf("H2 closed a held delivery: %v", err)
|
||||
if _, err := w.h.Close(IDOf("novox/app", "ffffffffffff0000"), "x"); err == nil {
|
||||
t.Fatal("H2 closed a held delivery")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,14 +359,3 @@ func TestGroupsNamesEachMembersPullRequestHeadBaseAndOpen(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// composed are the asks of a group's composed check among the checks asked: a member's own check has no group before its colon.
|
||||
func composed(checks []string) []string {
|
||||
var out []string
|
||||
for _, c := range checks {
|
||||
if !strings.HasPrefix(c, ":") {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user