Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79777d0f78 | ||
|
|
d2036f2a63 | ||
|
|
6c0024e43b | ||
|
|
a2e5a20f52 | ||
|
|
845479d7cd | ||
|
|
2ebcb18097 | ||
|
|
00e54d46d7 | ||
|
|
933370df7c | ||
|
|
437c524b81 | ||
|
|
dc96d7c2dd | ||
|
|
f9247342d1 | ||
|
|
b1d875410a | ||
|
|
89e15c7bcd | ||
|
|
a21da33d48 | ||
|
|
b53b0e7e2c | ||
|
|
48dd41db91 | ||
|
|
16331cdfdc | ||
|
|
ab3722d3b3 | ||
|
|
66c404fd39 | ||
|
|
3ad9d42be9 | ||
|
|
083515da1b | ||
|
|
ac5d257a1c | ||
|
|
896bd88380 | ||
|
|
24f50c7c86 | ||
|
|
e0bf87c2e3 | ||
|
|
ef1e98f867 | ||
|
|
e3db481491 | ||
|
|
21b4925892 | ||
|
|
098c6d3ec7 | ||
|
|
8d3f81c2c0 |
+181
-13
@@ -11,13 +11,23 @@
|
||||
// another module, after ${dir:<id>} is resolved within each module and both homes are read as one root.
|
||||
// Archives are not containers for this rule. This holds for ~/.ssh what checks/sshdir held (ADR 0415 §6).
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
// 5. a directory that is, lies inside or holds a directory a system package owns under /var/lib, or any other
|
||||
// resource inside one (issue 1841: pacman's state directory, placed with "place": ".", was /var/lib/pacman, the
|
||||
// package manager's own database directory). A placed directory is resolved as the mesh resolves it with the
|
||||
// default data root: "." is /var/lib/<module>, "mesh" /var/lib/mesh/<module>, neither /var/lib/<module>/<id>.
|
||||
// The system directories are checks/owned/system, captured from a machine (pacman -Qo, /usr/lib/tmpfiles.d);
|
||||
// checks/owned/system-known names the exceptions still to fix, by module, resource and issue. That list only
|
||||
// shrinks: an entry that no longer collides is refused, and with -base one the trunk's copy does not hold.
|
||||
//
|
||||
// go run . [-base <trunk's system-known>] <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
@@ -27,6 +37,12 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The lists of rule 5, relative to the catalogue's root.
|
||||
const (
|
||||
SystemList = "checks/owned/system"
|
||||
SystemKnown = "checks/owned/system-known"
|
||||
)
|
||||
|
||||
// Home is the one root both machine homes are read as.
|
||||
const Home = "${home}"
|
||||
|
||||
@@ -43,9 +59,9 @@ type Finding struct {
|
||||
}
|
||||
|
||||
type resource struct {
|
||||
ID, Type, Path, Unknown string
|
||||
Knows []string
|
||||
HasKnows bool
|
||||
ID, Type, Path, Unknown, Place string
|
||||
Knows []string
|
||||
HasKnows bool
|
||||
}
|
||||
|
||||
type parsed struct {
|
||||
@@ -65,6 +81,7 @@ func load(file string) (*parsed, error) {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
Place string `json:"place"`
|
||||
Unknown *string `json:"unknown"`
|
||||
Knows []string `json:"knows"`
|
||||
} `json:"resources"`
|
||||
@@ -81,7 +98,7 @@ func load(file string) (*parsed, error) {
|
||||
out.Module = filepath.Base(filepath.Dir(file))
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
x := resource{ID: r.ID, Type: r.Type, Path: r.Path, Knows: r.Knows, HasKnows: r.Knows != nil}
|
||||
x := resource{ID: r.ID, Type: r.Type, Path: r.Path, Place: r.Place, Knows: r.Knows, HasKnows: r.Knows != nil}
|
||||
if r.Unknown != nil {
|
||||
x.Unknown = *r.Unknown
|
||||
if x.Unknown == "" {
|
||||
@@ -143,14 +160,23 @@ type declared struct {
|
||||
}
|
||||
|
||||
// Check reads every manifest under root/modules and answers what it refuses.
|
||||
func Check(root string) ([]Finding, error) {
|
||||
func Check(root string, base []string) ([]Finding, error) {
|
||||
files, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(files)
|
||||
system, err := readSystem(filepath.Join(root, SystemList))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
known, err := readLines(filepath.Join(root, SystemKnown))
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
var out []Finding
|
||||
var paths []declared
|
||||
collided := map[string]bool{}
|
||||
type container struct{ module, path string }
|
||||
var containers []container
|
||||
for _, file := range files {
|
||||
@@ -170,6 +196,7 @@ func Check(root string) ([]Finding, error) {
|
||||
dirs[r.ID] = "/placed/" + m.Module + "/" + r.ID
|
||||
}
|
||||
}
|
||||
out = append(out, inSystem(m, system, known, collided)...)
|
||||
for _, r := range m.Resources {
|
||||
p := norm(r.Path, dirs)
|
||||
if p != "" {
|
||||
@@ -212,6 +239,28 @@ func Check(root string) ([]Finding, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range known {
|
||||
if !collided[k] {
|
||||
module, id, _ := strings.Cut(k, " ")
|
||||
id, _, _ = strings.Cut(id, " ")
|
||||
out = append(out, Finding{5, module, id, fmt.Sprintf("is listed in %s, and no longer lies in a system "+
|
||||
"package's directory: take the line out, so the list only shrinks", SystemKnown)})
|
||||
}
|
||||
}
|
||||
if base != nil {
|
||||
held := map[string]bool{}
|
||||
for _, b := range base {
|
||||
held[b] = true
|
||||
}
|
||||
for _, k := range known {
|
||||
if !held[k] {
|
||||
module, id, _ := strings.Cut(k, " ")
|
||||
id, _, _ = strings.Cut(id, " ")
|
||||
out = append(out, Finding{5, module, id, fmt.Sprintf("is a new line in %s, which the trunk's copy "+
|
||||
"does not hold: a module's directory is placed outside a system package's, never listed", SystemKnown)})
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, d := range paths {
|
||||
for _, c := range containers {
|
||||
if c.module != d.module && (d.path == c.path || strings.HasPrefix(d.path, c.path+"/")) {
|
||||
@@ -222,21 +271,140 @@ func Check(root string) ([]Finding, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
root := "."
|
||||
if len(os.Args) > 1 {
|
||||
root = os.Args[1]
|
||||
// systemDir is one directory a system package owns, and what says so.
|
||||
type systemDir struct{ Path, Source string }
|
||||
|
||||
// readLines is a list's lines, without comments and blank lines.
|
||||
func readLines(file string) ([]string, error) {
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found, err := Check(root)
|
||||
var lines []string
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if line = strings.TrimSpace(line); line != "" && !strings.HasPrefix(line, "#") {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
// readSystem is the list of system directories: "<absolute path> <package>: <how it was read>" per line. Absent or
|
||||
// empty, the check fails: a list that says nothing would let every module through.
|
||||
func readSystem(file string) ([]systemDir, error) {
|
||||
lines, err := readLines(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rule 5 reads the system packages' directories from %s: %w", file, err)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return nil, fmt.Errorf("%s names no system package's directory, so rule 5 would pass everything", file)
|
||||
}
|
||||
var out []systemDir
|
||||
for _, line := range lines {
|
||||
p, source, _ := strings.Cut(line, " ")
|
||||
if !strings.HasPrefix(p, "/var/lib/") || strings.TrimSpace(source) == "" {
|
||||
return nil, fmt.Errorf("%s: %q is not \"/var/lib/<directory> <package>: <how it was read>\"", file, line)
|
||||
}
|
||||
out = append(out, systemDir{path.Clean(p), strings.TrimSpace(source)})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// placedAt is where the mesh places a pathless directory with the default data root (mesh-sdk catalogue dirsFor).
|
||||
func placedAt(module string, r resource) string {
|
||||
switch r.Place {
|
||||
case ".":
|
||||
return "/var/lib/" + module
|
||||
case "mesh":
|
||||
return "/var/lib/mesh/" + module
|
||||
}
|
||||
return "/var/lib/" + module + "/" + r.ID
|
||||
}
|
||||
|
||||
// inSystem is rule 5 for one module: each resource whose resolved path collides with a system package's directory,
|
||||
// unless system-known lists it (then it is marked collided, so a stale line is found).
|
||||
func inSystem(m *parsed, system []systemDir, known []string, collided map[string]bool) []Finding {
|
||||
real := map[string]string{}
|
||||
for _, r := range m.Resources {
|
||||
if r.Type == "directory" && r.Path == "" && r.ID != "" {
|
||||
real[r.ID] = placedAt(m.Module, r)
|
||||
}
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if r.ID != "" && r.Path != "" {
|
||||
real[r.ID] = r.Path
|
||||
}
|
||||
}
|
||||
var out []Finding
|
||||
for _, r := range m.Resources {
|
||||
p := ""
|
||||
if r.Path != "" {
|
||||
p = norm(r.Path, real)
|
||||
} else if r.Type == "directory" {
|
||||
p = real[r.ID]
|
||||
}
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
continue
|
||||
}
|
||||
for _, s := range system {
|
||||
inside := p == s.Path || strings.HasPrefix(p, s.Path+"/")
|
||||
holds := r.Type == "directory" && strings.HasPrefix(s.Path, p+"/")
|
||||
if !inside && !holds {
|
||||
continue
|
||||
}
|
||||
listed := ""
|
||||
for _, k := range known {
|
||||
if strings.HasPrefix(k, m.Module+" "+r.ID+" ") || k == m.Module+" "+r.ID {
|
||||
listed = k
|
||||
}
|
||||
}
|
||||
if listed != "" {
|
||||
collided[listed] = true
|
||||
break
|
||||
}
|
||||
how := "is or lies inside"
|
||||
if holds {
|
||||
how = "holds"
|
||||
}
|
||||
out = append(out, Finding{5, m.Module, r.ID, fmt.Sprintf("resolves to %q, which %s %s, the directory of the "+
|
||||
"system package %s; place it under /var/lib/mesh/%s (\"place\": \"mesh\") or a path of its own", p, how,
|
||||
s.Path, s.Source, m.Module)})
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func main() {
|
||||
baseFile := flag.String("base", "", "the trunk's copy of "+SystemKnown+": a line it does not hold is refused")
|
||||
flag.Parse()
|
||||
root := "."
|
||||
if flag.NArg() > 0 {
|
||||
root = flag.Arg(0)
|
||||
}
|
||||
var base []string
|
||||
if *baseFile != "" {
|
||||
lines, err := readLines(*baseFile)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
base = append([]string{}, lines...)
|
||||
}
|
||||
found, err := Check(root, base)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, f := range found {
|
||||
fmt.Printf("%s: %s: %s (rule %d, novox/hq ADR 0422 §8, issue 1024)\n", f.Module, f.ID, f.Why, f.Rule)
|
||||
cite := "novox/hq ADR 0422 §8, issue 1024"
|
||||
if f.Rule == 5 {
|
||||
cite = "issue 1841"
|
||||
}
|
||||
fmt.Printf("%s: %s: %s (rule %d, %s)\n", f.Module, f.ID, f.Why, f.Rule, cite)
|
||||
}
|
||||
if len(found) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("owned paths: every directory and archive says how it treats unknown files; no module declares inside another's directory")
|
||||
fmt.Println("owned paths: every directory and archive says how it treats unknown files; no module declares inside another's directory, or in a system package's")
|
||||
}
|
||||
|
||||
+102
-1
@@ -20,7 +20,10 @@ func manifest(t *testing.T, root, module, body string) {
|
||||
|
||||
func check(t *testing.T, root string) []Finding {
|
||||
t.Helper()
|
||||
found, err := Check(root)
|
||||
if _, err := os.Stat(filepath.Join(root, SystemList)); os.IsNotExist(err) {
|
||||
lists(t, root, "/var/lib/pacman pacman: pacman -Qo\n", "")
|
||||
}
|
||||
found, err := Check(root, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -35,6 +38,20 @@ func only(t *testing.T, found []Finding, rule int, module, id string) {
|
||||
}
|
||||
}
|
||||
|
||||
// lists writes the catalogue's list of system directories and its known exceptions.
|
||||
func lists(t *testing.T, root, system, known string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Join(root, "checks", "owned"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, SystemList), []byte(system), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, SystemKnown), []byte(known), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func dir(id, path, rest string) string {
|
||||
return `{"id":"` + id + `","type":"directory","path":"` + path + `"` + rest + `}`
|
||||
}
|
||||
@@ -145,3 +162,87 @@ func TestPlacedDirectoriesOfTwoModulesAreTheirOwn(t *testing.T) {
|
||||
t.Fatalf("refused %+v", found)
|
||||
}
|
||||
}
|
||||
|
||||
// 1841/AC18: a module's directory that resolves to a /var/lib directory a system package owns, lies inside one or
|
||||
// holds one, is refused, whether placed (place ".", or by default beneath the module's own) or stated; a placed
|
||||
// directory saying place "mesh" lies under /var/lib/mesh and passes (issue 1841: pacman's state was /var/lib/pacman).
|
||||
func TestADirectoryOfASystemPackageIsRefused(t *testing.T) {
|
||||
system := "/var/lib/pacman pacman: pacman -Qo\n/var/lib/systemd/coredump systemd: tmpfiles.d/systemd.conf\n"
|
||||
for name, resources := range map[string][]string{
|
||||
"placed-own": {`{"id":"state","type":"directory","place":".","unknown":"program"}`},
|
||||
"placed-beneath": {`{"id":"coredump","type":"directory","unknown":"program"}`},
|
||||
"stated-inside": {dir("d", "/var/lib/pacman/keeper", `,"unknown":"program"`)},
|
||||
"stated-holding": {dir("d", "/var/lib/systemd", `,"unknown":"program"`)},
|
||||
"file-inside": {`{"id":"state","type":"directory","place":"mesh","unknown":"program"}`,
|
||||
`{"id":"f","type":"file","path":"/var/lib/pacman/keeper.json"}`},
|
||||
} {
|
||||
module := map[string]string{"placed-own": "pacman", "placed-beneath": "systemd"}[name]
|
||||
if module == "" {
|
||||
module = "keeper"
|
||||
}
|
||||
root := t.TempDir()
|
||||
lists(t, root, system, "")
|
||||
manifest(t, root, module, mod(module, resources...))
|
||||
found := check(t, root)
|
||||
if len(found) != 1 || found[0].Rule != 5 || found[0].Module != module ||
|
||||
!strings.Contains(found[0].Why, "/var/lib/") || !strings.Contains(found[0].Why, "package") {
|
||||
t.Errorf("%s: found %+v; want one finding of rule 5 for %s naming the path and the package", name, found, module)
|
||||
}
|
||||
}
|
||||
root := t.TempDir()
|
||||
lists(t, root, system, "")
|
||||
manifest(t, root, "pacman", mod("pacman",
|
||||
`{"id":"state","type":"directory","place":"mesh","unknown":"program"}`,
|
||||
`{"id":"f","type":"file","path":"${dir:state}/keeper.json"}`,
|
||||
dir("other", "/var/lib/pacman-other", `,"unknown":"program"`)))
|
||||
if found := check(t, root); len(found) != 0 {
|
||||
t.Fatalf("a directory under /var/lib/mesh/pacman was refused: %+v", found)
|
||||
}
|
||||
}
|
||||
|
||||
// 1841/AC18: a known exception passes; one that no longer collides is refused, so the list only shrinks; one the trunk's
|
||||
// copy does not hold is refused; and an absent list of system directories fails the check rather than passing it.
|
||||
func TestKnownSystemExceptionsOnlyShrink(t *testing.T) {
|
||||
system := "/var/lib/systemd systemd: tmpfiles.d/systemd.conf\n"
|
||||
known := "# comment\nsystemd state issue 1873\n"
|
||||
root := t.TempDir()
|
||||
lists(t, root, system, known)
|
||||
manifest(t, root, "systemd", mod("systemd", `{"id":"state","type":"directory","place":".","unknown":"program"}`))
|
||||
if found := check(t, root); len(found) != 0 {
|
||||
t.Fatalf("a known exception was refused: %+v", found)
|
||||
}
|
||||
if found, err := Check(root, []string{"systemd state issue 1873"}); err != nil || len(found) != 0 {
|
||||
t.Fatalf("a known exception held by the trunk was refused: %+v %v", found, err)
|
||||
}
|
||||
if found, err := Check(root, []string{}); err != nil || len(found) != 1 || found[0].Rule != 5 {
|
||||
t.Fatalf("an exception the trunk does not hold passed: %+v %v", found, err)
|
||||
}
|
||||
|
||||
manifest(t, root, "systemd", mod("systemd", `{"id":"state","type":"directory","place":"mesh","unknown":"program"}`))
|
||||
only(t, check(t, root), 5, "systemd", "state")
|
||||
|
||||
root = t.TempDir()
|
||||
manifest(t, root, "a", mod("a"))
|
||||
if err := os.MkdirAll(filepath.Join(root, "checks", "owned"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, SystemList), []byte("# nothing\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Check(root, nil); err == nil {
|
||||
t.Fatal("an empty list of system directories passed")
|
||||
}
|
||||
}
|
||||
|
||||
// 1841/AC18: the catalogue as it stands passes rule 5 with its own lists.
|
||||
func TestTheCatalogueKeepsOutOfSystemDirectories(t *testing.T) {
|
||||
found, err := Check("../..", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range found {
|
||||
if f.Rule == 5 {
|
||||
t.Errorf("%s: %s: %s", f.Module, f.ID, f.Why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# The directories under /var/lib that a system package owns (rule 5 of checks/owned, issue 1841). A module's
|
||||
# directory, placed or stated, may not be one of these, lie inside one or hold one: the package's program and the
|
||||
# package manager keep their own files there, and the node-engine would judge, chmod or chown the directory.
|
||||
#
|
||||
# One line per directory: "<path> <package>: <how it was read>". Captured on the workstation on 2026-10-22, through
|
||||
# the mesh: pacman's own database by `pacman -Qo` (pacman_owns), the rest from the `d`, `D` and `Q` lines of
|
||||
# /usr/lib/tmpfiles.d (node-reads.search for "/var/lib/"). A directory found to belong to a package is added here.
|
||||
/var/lib/pacman pacman: pacman -Qo /var/lib/pacman/ (pacman 7.1.0)
|
||||
/var/lib/alsa alsa-utils: tmpfiles.d/alsa-utils.conf
|
||||
/var/lib/colord colord: tmpfiles.d/colord.conf
|
||||
/var/lib/containers podman: tmpfiles.d/podman.conf
|
||||
/var/lib/dbus dbus: tmpfiles.d/dbus.conf
|
||||
/var/lib/geoclue geoclue: tmpfiles.d/geoclue.conf
|
||||
/var/lib/lastlog util-linux: tmpfiles.d/lastlog2.conf
|
||||
/var/lib/libuuid util-linux: tmpfiles.d/uuidd.conf
|
||||
/var/lib/machines systemd: tmpfiles.d/systemd-nspawn.conf
|
||||
/var/lib/openvpn/chroot networkmanager-openvpn: tmpfiles.d/nm-openvpn-tmpfiles.conf
|
||||
/var/lib/portables systemd: tmpfiles.d/portables.conf
|
||||
/var/lib/postgres postgresql: tmpfiles.d/postgresql.conf
|
||||
/var/lib/private systemd: tmpfiles.d/systemd.conf
|
||||
/var/lib/rpcbind rpcbind: tmpfiles.d/rpcbind.conf
|
||||
/var/lib/systemd systemd: tmpfiles.d/systemd.conf
|
||||
/var/lib/tpm2-tss/system/keystore tpm2-tss: tmpfiles.d/tpm2-tss-fapi.conf
|
||||
@@ -0,0 +1,5 @@
|
||||
# Modules whose directory lies in a system package's directory today (rule 5 of checks/owned, issue 1841), each with
|
||||
# the issue that moves it out: "<module> <resource id> issue <number>". This list only shrinks: a line whose module
|
||||
# no longer collides is refused, and so is a line the trunk's copy does not hold.
|
||||
postgres state issue 1873
|
||||
systemd state issue 1873
|
||||
+15
-3
@@ -48,7 +48,8 @@
|
||||
# 10. the mail-store seat (novox/hq ADR 0378 §3, issue 800): no manifest but modules/mesh-mail claims it.
|
||||
# 11. owned paths (novox/hq ADR 0422 §8, issue 1024; it holds ~/.ssh for ssh-client as ADR 0415 §6 asked):
|
||||
# every directory and archive says `unknown` (move-out, module or program) and a valid `knows`; `move-out`
|
||||
# only under a home; and no module declares a path inside a directory of another module.
|
||||
# only under a home; no module declares a path inside a directory of another module; and none is, lies in or
|
||||
# holds a directory a system package owns under /var/lib (checks/owned/system, issue 1841).
|
||||
# checks/sshdir (ADR 0415 §6) stays while main runs it; it goes under issue 1035 once main runs checks/owned.
|
||||
# 12. the image builder (novox/hq ADR 0487 §3, issue 1704): only modules/buildah provides rootless-image-build.
|
||||
# 13. the image's place (novox/hq ADR 0487 §1, issue 1707): every image builds from a Dockerfile in its own module's directory.
|
||||
@@ -73,8 +74,19 @@ set -eu
|
||||
# ~/.ssh (novox/hq ADR 0415 §6): only ssh-client may name a path under it. Main's merge check runs this line, so
|
||||
# checks/sshdir stays until main runs checks/owned; it goes under issue 1035.
|
||||
(cd checks/sshdir && go test -count=1 . && go run . ../..)
|
||||
# Owned paths (novox/hq ADR 0422 §8): unknown files named per directory; no path inside another module's directory.
|
||||
(cd checks/owned && go test -count=1 . && go run . ../..)
|
||||
# Owned paths (novox/hq ADR 0422 §8): unknown files named per directory; no path inside another module's directory;
|
||||
# none in a system package's directory under /var/lib (issue 1841), the known exceptions only shrinking: judged
|
||||
# against the trunk's copy of checks/owned/system-known at the merge base; a trunk without one is the list's first copy.
|
||||
owned_base=""
|
||||
if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then
|
||||
if git cat-file -e "$merge_base:checks/owned/system-known" 2>/dev/null; then
|
||||
owned_base=$(mktemp)
|
||||
git show "$merge_base:checks/owned/system-known" >"$owned_base"
|
||||
fi
|
||||
else
|
||||
echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; checks/owned/system-known is held to the list it was first written with"
|
||||
fi
|
||||
(cd checks/owned && go test -count=1 . && if [ -n "$owned_base" ]; then go run . -base "$owned_base" ../..; else go run . ../..; fi)
|
||||
# The secrets (novox/hq issue 342): a password is not a setting, so no setting can replace the vault's. The
|
||||
# known list only shrinks: judged against the trunk's copy at the merge base, a line it does not hold is refused.
|
||||
secrets_base=""
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# The test script compiles the sources beside themselves before the tests import them (package.json, issue 1891).
|
||||
*.js
|
||||
@@ -0,0 +1,188 @@
|
||||
// **mesh/check is written from the checker's owed state, read by a watch** (ADR 0521; R4e round 2 finding R2-1; finding K1
|
||||
// before it; novox/hq issue 1301; ADR 0447 §1, ADR 0503 §3: main requires mesh/check alone).
|
||||
//
|
||||
// No tool writes mesh/check: a tool is told no caller, and the console every agent session speaks through (node-tools)
|
||||
// invokes every tool, so a tool that wrote it could be called by any session to merge past the checker (the fault issue
|
||||
// 293 closed for the two statuses before it). Nor does an event: nothing in the delivery plan orders this module before the
|
||||
// forge bridge, and a durable consumer whose filters change keeps its deliver policy, so an event said before this module
|
||||
// consumed it was lost. The checker keeps what it owes the forge in its state `owed` (ADR 0484 §2), which it announces
|
||||
// readable (ADR 0495 §2-§3), and only the checker writes it. This module watches it: the watch hands over the latest value of
|
||||
// every key when it starts, whenever that is, and then every change. Each key whose act is the status mesh/check is
|
||||
// written on its head, once per revision; a revision older than one already written on the key is dropped.
|
||||
//
|
||||
// A read refused (the grant not yet composed) is said once and asked again on a bound until it holds. A forge that refuses
|
||||
// a status is asked again on a bound, until it writes or a newer revision of the key comes.
|
||||
|
||||
import type { CommitStatus } from "./client.js";
|
||||
import { bindUntilHeld, bindWait } from "./bind.js";
|
||||
import { checkName } from "./guard.js";
|
||||
import { CHECK_STATUS } from "./pulls.js";
|
||||
|
||||
/** The checker's state this module reads, and the act of its keys this module writes. */
|
||||
export const CHECKER_OWED = "mesh-delivery-checker.owed";
|
||||
export const CHECK_ACT = `status=${CHECK_STATUS}`;
|
||||
|
||||
const states = new Set(["pending", "success", "error", "failure", "warning"]);
|
||||
|
||||
/** What writing the check needs of the forge. */
|
||||
export interface CheckForge {
|
||||
setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void>;
|
||||
}
|
||||
|
||||
/** One change of the checker's owed, as the SDK's state watch hands it. */
|
||||
export interface OwedChange {
|
||||
key: string;
|
||||
op: "put" | "delete";
|
||||
value?: unknown;
|
||||
revision: number;
|
||||
current?: boolean;
|
||||
}
|
||||
|
||||
/** A key part as mesh-sdk go/delivery EscapeName writes it, read back: `=` and two lowercase hex digits per byte. */
|
||||
function unescapeName(part: string): string {
|
||||
if (!/^([-/_a-zA-Z0-9]|=[0-9a-f]{2})+$/.test(part)) throw new Error(`${JSON.stringify(part)} is not an escaped name`);
|
||||
return Buffer.from(part.replace(/=([0-9a-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16))), "latin1").toString("utf8");
|
||||
}
|
||||
|
||||
/** The pull request and head of the checker's owed key (mesh-sdk go/delivery CheckerOwedKey):
|
||||
* `<owner>/<name>.<number>.<head>.status=mesh/check`, owner and name escaped. */
|
||||
export function owedKeyOf(key: string): { owner: string; repo: string; number: number; sha: string } {
|
||||
const parts = String(key ?? "").split(".");
|
||||
if (parts.length !== 4) throw new Error(`${JSON.stringify(key)} is not an owed key, <repository>.<number>.<head>.<act>`);
|
||||
const [repository, number, sha, act] = parts;
|
||||
if (act !== CHECK_ACT) throw new Error(`${JSON.stringify(key)} owes ${JSON.stringify(act)}, not ${CHECK_ACT}`);
|
||||
const m = /^([^/]+)\/([^/]+)$/.exec(repository);
|
||||
if (!m) throw new Error(`${JSON.stringify(key)} names no <owner>/<name>`);
|
||||
if (!/^(0|[1-9][0-9]*)$/.test(number)) throw new Error(`${JSON.stringify(key)} names no pull request number`);
|
||||
if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha)) throw new Error(`${JSON.stringify(key)} names no head's whole sha`);
|
||||
return { owner: checkName("owner", unescapeName(m[1])), repo: checkName("name", unescapeName(m[2])), number: Number(number), sha };
|
||||
}
|
||||
|
||||
/** The status an owed change asks for, checked as mesh-sdk go/delivery DecodeCheckerOwed checks it; or why it is not one. */
|
||||
export function checkStatusOf(change: OwedChange): { owner: string; repo: string; sha: string; revision: number; status: CommitStatus } {
|
||||
if (change.op !== "put") throw new Error(`a ${change.op} owes nothing`);
|
||||
const { owner, repo, sha } = owedKeyOf(change.key);
|
||||
const revision = change.revision;
|
||||
if (typeof revision !== "number" || !Number.isSafeInteger(revision) || revision < 0) {
|
||||
throw new Error(`${JSON.stringify(revision)} is not a revision`);
|
||||
}
|
||||
const v = change.value;
|
||||
if (!v || typeof v !== "object") throw new Error("the value is no owed act");
|
||||
const o = v as Record<string, unknown>;
|
||||
if (o.v !== 1) throw new Error(`the owed act's version ${JSON.stringify(o.v)} is not one this module reads`);
|
||||
if (o.act !== "status" || o.context !== CHECK_STATUS) {
|
||||
throw new Error(`the owed act is ${JSON.stringify(o.act)} ${JSON.stringify(o.context)}, not the status ${CHECK_STATUS}`);
|
||||
}
|
||||
const state = String(o.state ?? "");
|
||||
if (!states.has(state)) throw new Error(`${JSON.stringify(o.state)} is not a status the forge keeps`);
|
||||
let description = String(o.text ?? "").replace(/\s+/g, " ").trim();
|
||||
if (description.length > 140) description = description.slice(0, 139) + "…";
|
||||
const target = typeof o.target === "string" && /^https?:\/\//.test(o.target) ? o.target : undefined;
|
||||
const status: CommitStatus = { state: state as CommitStatus["state"], context: CHECK_STATUS, description,
|
||||
...(target ? { target_url: target } : {}) };
|
||||
return { owner, repo, sha, revision, status };
|
||||
}
|
||||
|
||||
/** The one writer of mesh/check: each owed revision of the checker's, in revision order per key. */
|
||||
export class CheckStatuses {
|
||||
/** The newest revision written of each key, and the newest seen; the oldest keys go past what any forge holds open. */
|
||||
private written = new Map<string, number>();
|
||||
private seen = new Map<string, number>();
|
||||
/** The turn each key's writes wait on, and the asks again still running. */
|
||||
private turns = new Map<string, Promise<unknown>>();
|
||||
private again = new Set<Promise<void>>();
|
||||
private forge: CheckForge;
|
||||
private say: (line: string) => void;
|
||||
private sleep: (ms: number) => Promise<void>;
|
||||
|
||||
constructor(forge: CheckForge, say: (line: string) => void = (l) => console.error(l),
|
||||
sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms))) {
|
||||
this.forge = forge;
|
||||
this.say = say;
|
||||
this.sleep = sleep;
|
||||
}
|
||||
|
||||
/** Write the change's status: "written", or "dropped" for one that owes no mesh/check or is older than one written. A
|
||||
* forge that refuses throws. */
|
||||
async apply(change: OwedChange): Promise<"written" | "dropped"> {
|
||||
let asked;
|
||||
try {
|
||||
asked = checkStatusOf(change);
|
||||
} catch (err) {
|
||||
this.say(`[gitea] ${CHECKER_OWED} ${change?.key} at revision ${change?.revision} writes nothing: ${err instanceof Error ? err.message : err}`);
|
||||
return "dropped";
|
||||
}
|
||||
const key = change.key;
|
||||
const seen = this.seen.get(key);
|
||||
if (seen === undefined || asked.revision > seen) this.remember(this.seen, key, asked.revision);
|
||||
// One key's writes take turns, so two at once are weighed one after the other.
|
||||
const before = this.turns.get(key) ?? Promise.resolve();
|
||||
const turn = before.then(async (): Promise<"written" | "dropped"> => {
|
||||
const newest = this.written.get(key);
|
||||
if (newest !== undefined && asked.revision < newest) {
|
||||
this.say(`[gitea] ${asked.owner}/${asked.repo}@${asked.sha.slice(0, 12)}: mesh/check at revision ${asked.revision} is older than ${newest}, already written, and is dropped`);
|
||||
return "dropped";
|
||||
}
|
||||
await this.forge.setCommitStatus(asked.owner, asked.repo, asked.sha, asked.status);
|
||||
this.remember(this.written, key, asked.revision);
|
||||
return "written";
|
||||
});
|
||||
const settled = turn.catch(() => {});
|
||||
this.turns.set(key, settled);
|
||||
void settled.then(() => { if (this.turns.get(key) === settled) this.turns.delete(key); });
|
||||
return turn;
|
||||
}
|
||||
|
||||
/** What the watch hands over: written, or when the forge refuses, asked again on a bound in the background until it is
|
||||
* written or a newer revision of the key was seen. Never throws: a watch's handler that throws loses the change. */
|
||||
async take(change: OwedChange): Promise<void> {
|
||||
try {
|
||||
await this.apply(change);
|
||||
} catch (err) {
|
||||
const run = this.askAgain(change, err);
|
||||
this.again.add(run);
|
||||
void run.finally(() => this.again.delete(run));
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves when no ask again is running. */
|
||||
async settled(): Promise<void> {
|
||||
while (this.again.size) await Promise.all([...this.again]);
|
||||
}
|
||||
|
||||
private async askAgain(change: OwedChange, first: unknown): Promise<void> {
|
||||
let err = first;
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const wait = bindWait(attempt);
|
||||
this.say(`[gitea] ${change.key}: mesh/check at revision ${change.revision} could not be written (${err instanceof Error ? err.message : err}); asked again in ${wait / 1000}s`);
|
||||
await this.sleep(wait);
|
||||
const newest = this.seen.get(change.key);
|
||||
if (newest !== undefined && newest > change.revision) return;
|
||||
try {
|
||||
await this.apply(change);
|
||||
return;
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private remember(map: Map<string, number>, key: string, revision: number): void {
|
||||
map.delete(key);
|
||||
map.set(key, revision);
|
||||
if (map.size > 4000) map.delete(map.keys().next().value as string);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch the checker's owed with `watch` (the SDK's `state(CHECKER_OWED).watch`), handing each change to `checks`. A watch
|
||||
* refused — the read not granted yet — is said once and asked again on a bound until it holds (bind.ts). Never rejects.
|
||||
*/
|
||||
export async function watchOwed(watch: (handler: (change: OwedChange) => Promise<void>) => Promise<void>, checks: CheckStatuses,
|
||||
sleep?: (ms: number) => Promise<void>, say: (line: string) => void = (l) => console.error(l)): Promise<void> {
|
||||
let refusals = 0;
|
||||
await bindUntilHeld(CHECKER_OWED, () => watch((change) => checks.take(change)), sleep, (line) => {
|
||||
if (/could not be bound/.test(line) && refusals++ > 0) return;
|
||||
say(line);
|
||||
});
|
||||
}
|
||||
+10
-10
@@ -699,9 +699,9 @@ export class GiteaClient {
|
||||
};
|
||||
}
|
||||
|
||||
/** Make a branch require these commit statuses to merge (novox/hq ADR 0237): the rule named for the
|
||||
* branch is edited — its required statuses replaced by these, everything else it says kept unless
|
||||
* asked — or created, refusing direct pushes unless asked otherwise. Answers the rule as it is now. */
|
||||
/** Protect a branch (novox/hq ADR 0237): the rule named for the branch is edited — its required statuses taken
|
||||
* off (ADR 0534 §5), everything else it says kept unless asked — or created, refusing direct pushes unless asked
|
||||
* otherwise. Answers the rule as it is now. */
|
||||
async setBranchProtection(owner: string, repo: string, branch: string, want: ProtectionWanted): Promise<{ created: boolean; rule: BranchProtection }> {
|
||||
const had = await this.branchProtection(owner, repo, branch);
|
||||
const body = protectionBody(want, !had, branch);
|
||||
@@ -932,9 +932,9 @@ export class GiteaAdmin {
|
||||
return Array.isArray(res.body) ? res.body : [];
|
||||
}
|
||||
|
||||
/** Put main's rule right in every repository of an organisation (novox/hq ADR 0398 §2, issue 948): no push, and
|
||||
* only the tools' account merges. The rule there is edited, its statuses left as they are; a repository with no
|
||||
* rule for main gets one that requires none. Answers the repositories it touched. With the administrator's login:
|
||||
/** Put main's rule right in every repository of an organisation (novox/hq ADR 0398 §2, issue 948): no push, only
|
||||
* the tools' account merges, and no status required (ADR 0534 §1). The rule there is edited; a repository with no
|
||||
* rule for main gets one. Answers the repositories it touched. With the administrator's login:
|
||||
* the tools' account is no repository's administrator, so no tool can do this (ADR 0365 §2). */
|
||||
async ensureMainMerges(org: string): Promise<string[]> {
|
||||
const repos: string[] = [];
|
||||
@@ -954,11 +954,11 @@ export class GiteaAdmin {
|
||||
if (patch.status !== 200) GiteaAdmin.fail(rule, patch);
|
||||
continue;
|
||||
}
|
||||
// A rule named exactly main is matched before any glob rule, so it would hide the statuses, approvals and
|
||||
// administrator limit a glob rule covering main asks: they are copied into it (issue 948, review round 1).
|
||||
// A rule named exactly main is matched before any glob rule, so it would hide the approvals and administrator
|
||||
// limit a glob rule covering main asks: they are copied into it (issue 948, review round 1). Its statuses are
|
||||
// not: main requires none (ADR 0534 §1).
|
||||
const covering = rules.filter((r) => ruleCovers(String(r.rule_name ?? r.branch_name ?? ""), MAIN_BRANCH));
|
||||
const checks = [...new Set(covering.filter((r) => r.enable_status_check).flatMap((r) => (r.status_check_contexts ?? []).filter(Boolean)))];
|
||||
const copied: Record<string, unknown> = { enable_status_check: checks.length > 0, status_check_contexts: checks };
|
||||
const copied: Record<string, unknown> = {};
|
||||
const approvals = Math.max(0, ...covering.map((r) => Number(r.required_approvals ?? 0)));
|
||||
if (approvals > 0) copied.required_approvals = approvals;
|
||||
if (covering.some((r) => r.block_admin_merge_override === true)) copied.block_admin_merge_override = true;
|
||||
|
||||
@@ -1,134 +1,135 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The tests of issue 1301 (ADR 0447 §1, §5; ADR 0503 §3, §5; lead decisions L4 and L11): main requires `mesh/check`
|
||||
// alone in place of `mesh/merge-gate` and `mesh/repo-check`; a main that requires `mesh/repo-check` without
|
||||
// `mesh/merge-gate` (hq) keeps it; every repository of the organisation whose main requires `mesh/merge-gate` is
|
||||
// switched, listed in the value or not; no other status is dropped.
|
||||
// The tests of ADR 0534 §1, §2 (the operator, 2026-10-23): the forge requires no status to merge. Main's rule keeps no
|
||||
// push, no push whitelist, the tools' account alone in its merge whitelist and no administrator merging past it, and
|
||||
// requires no status: the program writes it so on every repository it lists, and takes every required status off a
|
||||
// rule covering main in every repository of the organisation it does not list. Nothing else of an unlisted
|
||||
// repository's rule is sent.
|
||||
|
||||
func narrowedRule(contexts ...any) map[string]any {
|
||||
return map[string]any{
|
||||
"enable_push": false, "enable_push_whitelist": false, "push_whitelist_usernames": []any{},
|
||||
"enable_merge_whitelist": true, "merge_whitelist_usernames": []any{tools},
|
||||
"enable_status_check": true, "status_check_contexts": contexts,
|
||||
"enable_status_check": len(contexts) > 0, "status_check_contexts": contexts,
|
||||
"block_admin_merge_override": true,
|
||||
}
|
||||
}
|
||||
|
||||
// 1301-S1: a listed repository whose main requires the two old statuses is put right to `mesh/check` in their place,
|
||||
// every other status kept; a second pass changes nothing.
|
||||
func TestListedRepositorySwapsTheTwoStatusesForCheck(t *testing.T) {
|
||||
// requiresNone is whether a rule as the fake forge keeps it requires no status.
|
||||
func requiresNone(rule map[string]any) bool {
|
||||
return rule["enable_status_check"] != true && len(strs(rule["status_check_contexts"])) == 0
|
||||
}
|
||||
|
||||
// 0534-1: a listed repository whose main requires statuses has them all taken off, the rest of the rule kept; a
|
||||
// second pass changes nothing.
|
||||
func TestListedRepositoryRequiresNoStatus(t *testing.T) {
|
||||
f := newFake()
|
||||
repo := f.add("novox/mesh-delivery", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate", "mesh/repo-check", "other/x")}, "main")
|
||||
checked := f.add("novox/checked", map[string]map[string]any{mainRule: narrowedRule("mesh/check")}, "main")
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{"mesh-delivery": {}})
|
||||
got := strs(repo.Rules[mainRule]["status_check_contexts"])
|
||||
if strings.Join(got, ",") != "other/x,mesh/check" {
|
||||
t.Errorf("main requires %v, want other/x and mesh/check", got)
|
||||
}
|
||||
if r := result(o, "mesh-delivery"); r.Did != DidPutRight {
|
||||
t.Errorf("outcome %+v, want put right", r)
|
||||
o := run(t, s, Value{"mesh-delivery": {}, "checked": {}})
|
||||
for name, r := range map[string]*fRepo{"mesh-delivery": repo, "checked": checked} {
|
||||
rule := r.Rules[mainRule]
|
||||
if !requiresNone(rule) {
|
||||
t.Errorf("%s's main still requires %v", name, rule["status_check_contexts"])
|
||||
}
|
||||
if rule["enable_push"] != false || rule["enable_merge_whitelist"] != true ||
|
||||
strings.Join(strs(rule["merge_whitelist_usernames"]), ",") != tools || rule["block_admin_merge_override"] != true {
|
||||
t.Errorf("%s's main lost the rest of its rule: %v", name, rule)
|
||||
}
|
||||
if r := result(o, name); r.Did != DidPutRight {
|
||||
t.Errorf("%s outcome %+v, want put right", name, r)
|
||||
}
|
||||
}
|
||||
before := len(f.writes())
|
||||
run(t, s, Value{"mesh-delivery": {}})
|
||||
o = run(t, s, Value{"mesh-delivery": {}, "checked": {}})
|
||||
if w := f.writes(); len(w) != before {
|
||||
t.Errorf("a second pass wrote %v", w[before:])
|
||||
}
|
||||
if result(o, "checked").Did != DidUnchanged {
|
||||
t.Errorf("second outcome %+v, want unchanged", result(o, "checked"))
|
||||
}
|
||||
}
|
||||
|
||||
// 1301-S1: a repository the program makes requires `mesh/check` alone from its first rule in the organisation on.
|
||||
func TestNewRepositoryRequiresCheckAlone(t *testing.T) {
|
||||
// 0534-1: a repository the program makes requires no status from its first rule in the organisation on.
|
||||
func TestNewRepositoryRequiresNoStatus(t *testing.T) {
|
||||
f := newFake()
|
||||
s, _ := syncer(t, f)
|
||||
run(t, s, Value{"fresh": {Description: "d"}})
|
||||
rule := f.repos["novox/fresh"].Rules[mainRule]
|
||||
if got := strs(rule["status_check_contexts"]); rule["enable_status_check"] != true || strings.Join(got, ",") != CheckStatus {
|
||||
t.Errorf("a new repository's main requires %v, want %s alone", got, CheckStatus)
|
||||
if !requiresNone(rule) {
|
||||
t.Errorf("a new repository's main requires %v, want none", rule["status_check_contexts"])
|
||||
}
|
||||
if readme := f.repos["novox/fresh"].Files["README.md"]; !strings.Contains(readme, CheckStatus) ||
|
||||
strings.Contains(readme, "mesh/merge-gate") || strings.Contains(readme, "mesh/repo-check") {
|
||||
if readme := f.repos["novox/fresh"].Files["README.md"]; strings.Contains(readme, "mesh/merge-gate") || strings.Contains(readme, "mesh/repo-check") {
|
||||
t.Errorf("the README names the statuses of before: %q", readme)
|
||||
}
|
||||
}
|
||||
|
||||
// 1301-S1: mesh/check is never dropped, and a rule that requires it beside a retired status loses the retired one.
|
||||
func TestCheckIsKeptAndARetiredStatusBesideItGoes(t *testing.T) {
|
||||
// 0534-1: a listed repository whose main requires nothing stays so, and is not written when the rest is right.
|
||||
func TestListedRuleRequiringNothingIsRight(t *testing.T) {
|
||||
f := newFake()
|
||||
both := f.add("novox/both", map[string]map[string]any{mainRule: narrowedRule("mesh/check", "mesh/repo-check")}, "main")
|
||||
right := f.add("novox/right", map[string]map[string]any{mainRule: narrowedRule("mesh/check")}, "main")
|
||||
none := f.add("novox/none", map[string]map[string]any{mainRule: narrowedRule()}, "main")
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{"both": {}, "right": {}})
|
||||
if got := strs(both.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus {
|
||||
t.Errorf("both requires %v, want %s alone", got, CheckStatus)
|
||||
o := run(t, s, Value{"none": {}})
|
||||
if !requiresNone(none.Rules[mainRule]) {
|
||||
t.Errorf("none requires %v", none.Rules[mainRule]["status_check_contexts"])
|
||||
}
|
||||
if got := strs(right.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus {
|
||||
t.Errorf("right requires %v", got)
|
||||
if result(o, "none").Did != DidUnchanged {
|
||||
t.Errorf("outcome %+v, want unchanged", result(o, "none"))
|
||||
}
|
||||
if result(o, "right").Did != DidUnchanged || result(o, "both").Did != DidPutRight {
|
||||
t.Errorf("outcome %+v", o)
|
||||
for _, w := range f.writes() {
|
||||
t.Errorf("a right rule was written: %s", w)
|
||||
}
|
||||
}
|
||||
|
||||
// 1301-S1: a listed repository whose main requires nothing is given mesh/check, not the old two.
|
||||
func TestListedRuleRequiringNothingGetsCheck(t *testing.T) {
|
||||
f := newFake()
|
||||
none := f.add("novox/none", map[string]map[string]any{mainRule: {"enable_push": false}}, "main")
|
||||
s, _ := syncer(t, f)
|
||||
run(t, s, Value{"none": {}})
|
||||
if got := strs(none.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus {
|
||||
t.Errorf("none requires %v, want %s", got, CheckStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// 1301-S2 (ADR 0503 §5): a main that requires mesh/repo-check without mesh/merge-gate is judged by its own script
|
||||
// alone (hq): it keeps mesh/repo-check and is not given mesh/check, listed or not.
|
||||
func TestOwnCheckAloneIsKept(t *testing.T) {
|
||||
// 0534-1: hq, whose main required mesh/repo-check alone, requires no status either, listed or not.
|
||||
func TestOwnCheckAloneGoesToo(t *testing.T) {
|
||||
f := newFake()
|
||||
hq := f.add("novox/hq", map[string]map[string]any{mainRule: narrowedRule("mesh/repo-check")}, "main")
|
||||
lab := f.add("novox/mesh-lab", map[string]map[string]any{mainRule: narrowedRule("mesh/repo-check")}, "main")
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{"hq": {}})
|
||||
for name, repo := range map[string]*fRepo{"hq": hq, "mesh-lab": lab} {
|
||||
if got := strs(repo.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != "mesh/repo-check" {
|
||||
t.Errorf("%s requires %v, want mesh/repo-check alone", name, got)
|
||||
if !requiresNone(repo.Rules[mainRule]) {
|
||||
t.Errorf("%s requires %v, want none", name, repo.Rules[mainRule]["status_check_contexts"])
|
||||
}
|
||||
if r := result(o, name); r.Did != DidPutRight {
|
||||
t.Errorf("%s outcome %+v, want put right", name, r)
|
||||
}
|
||||
}
|
||||
if result(o, "hq").Did != DidUnchanged {
|
||||
t.Errorf("hq outcome %+v, want unchanged", result(o, "hq"))
|
||||
}
|
||||
for _, w := range f.writes() {
|
||||
t.Errorf("a repository judged by its own check alone was written: %s", w)
|
||||
}
|
||||
}
|
||||
|
||||
// 1301-S3 (L4, L11): a repository the value does not list whose main requires mesh/merge-gate has the two old
|
||||
// statuses swapped for mesh/check, and only its statuses: nothing else of its rule is sent. One that requires
|
||||
// neither, or mesh/repo-check alone, is not written to.
|
||||
func TestUnlistedRepositoryRequiringTheGateIsSwitched(t *testing.T) {
|
||||
// 0534-1: a repository the value does not list whose rule covering main requires any status has them taken off, and
|
||||
// only its statuses: nothing else of its rule is sent. One that requires none is not written to.
|
||||
func TestUnlistedRepositoryRequiringAStatusHasItTakenOff(t *testing.T) {
|
||||
f := newFake()
|
||||
catalog := f.add("novox/mesh-catalog", map[string]map[string]any{mainRule: {
|
||||
"enable_push": true, "enable_status_check": true, "status_check_contexts": []any{"mesh/merge-gate", "mesh/repo-check"},
|
||||
"enable_push": true, "enable_status_check": true, "status_check_contexts": []any{"mesh/check"},
|
||||
}}, "main")
|
||||
toolsGo := f.add("novox/mesh-tools-go", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate")}, "main")
|
||||
f.add("novox/plain", map[string]map[string]any{mainRule: {"enable_push": false}}, "main")
|
||||
f.add("novox/hq", map[string]map[string]any{mainRule: narrowedRule("mesh/repo-check")}, "main")
|
||||
f.add("novox/unticked", map[string]map[string]any{mainRule: {"enable_push": false, "enable_status_check": false,
|
||||
"status_check_contexts": []any{"mesh/check"}}}, "main")
|
||||
f.add("novox/norule", nil, "main")
|
||||
f.add("mesh-admin/elsewhere", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate")}, "main")
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{})
|
||||
|
||||
if got := strs(catalog.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus {
|
||||
t.Errorf("mesh-catalog requires %v, want %s", got, CheckStatus)
|
||||
if !requiresNone(catalog.Rules[mainRule]) {
|
||||
t.Errorf("mesh-catalog requires %v, want none", catalog.Rules[mainRule]["status_check_contexts"])
|
||||
}
|
||||
if catalog.Rules[mainRule]["enable_push"] != true {
|
||||
t.Errorf("an unlisted repository's rule was narrowed beyond its statuses: %v", catalog.Rules[mainRule])
|
||||
t.Errorf("an unlisted repository's rule was changed beyond its statuses: %v", catalog.Rules[mainRule])
|
||||
}
|
||||
if got := strs(toolsGo.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus {
|
||||
t.Errorf("mesh-tools-go requires %v, want %s", got, CheckStatus)
|
||||
if !requiresNone(toolsGo.Rules[mainRule]) {
|
||||
t.Errorf("mesh-tools-go requires %v, want none", toolsGo.Rules[mainRule]["status_check_contexts"])
|
||||
}
|
||||
want := []string{
|
||||
"PATCH /api/v1/repos/novox/mesh-catalog/branch_protections/main",
|
||||
@@ -142,7 +143,7 @@ func TestUnlistedRepositoryRequiringTheGateIsSwitched(t *testing.T) {
|
||||
t.Errorf("%s outcome %+v, want put right", n, r)
|
||||
}
|
||||
}
|
||||
for _, n := range []string{"plain", "hq", "norule", "elsewhere"} {
|
||||
for _, n := range []string{"plain", "unticked", "norule", "elsewhere"} {
|
||||
if r := result(o, n); r.Name != "" {
|
||||
t.Errorf("%s is in the outcome though nothing was done: %+v", n, r)
|
||||
}
|
||||
@@ -154,27 +155,27 @@ func TestUnlistedRepositoryRequiringTheGateIsSwitched(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1301-S3: the organisation's repositories are read page by page, so one past the first page is switched too.
|
||||
// 0534-1: the organisation's repositories are read page by page, so one past the first page is put right too.
|
||||
func TestUnlistedRepositoriesAreReadPastTheFirstPage(t *testing.T) {
|
||||
f := newFake()
|
||||
f.pageSize = 2
|
||||
for _, n := range []string{"a", "b", "c", "d", "e"} {
|
||||
f.add("novox/"+n, map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate", "mesh/repo-check")}, "main")
|
||||
f.add("novox/"+n, map[string]map[string]any{mainRule: narrowedRule("mesh/check")}, "main")
|
||||
}
|
||||
s, _ := syncer(t, f)
|
||||
run(t, s, Value{})
|
||||
for _, n := range []string{"a", "b", "c", "d", "e"} {
|
||||
if got := strs(f.repos["novox/"+n].Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus {
|
||||
t.Errorf("%s requires %v", n, got)
|
||||
if rule := f.repos["novox/"+n].Rules[mainRule]; !requiresNone(rule) {
|
||||
t.Errorf("%s requires %v", n, rule["status_check_contexts"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1301-S3: a refused swap is said with the repository and its step, and the pass goes on to the next.
|
||||
func TestRefusedSwapIsSaid(t *testing.T) {
|
||||
// 0534-1: a refused removal is said with the repository and its step, and the pass goes on to the next.
|
||||
func TestRefusedRemovalIsSaid(t *testing.T) {
|
||||
f := newFake()
|
||||
f.add("novox/a", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate")}, "main")
|
||||
f.add("novox/b", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate")}, "main")
|
||||
f.add("novox/a", map[string]map[string]any{mainRule: narrowedRule("mesh/check")}, "main")
|
||||
f.add("novox/b", map[string]map[string]any{mainRule: narrowedRule("mesh/check")}, "main")
|
||||
f.refuse = func(method, path string) (int, string) {
|
||||
if method == "PATCH" && strings.Contains(path, "/novox/a/") {
|
||||
return 403, "no"
|
||||
@@ -183,8 +184,8 @@ func TestRefusedSwapIsSaid(t *testing.T) {
|
||||
}
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{})
|
||||
if r := result(o, "a"); r.Did != DidRefused || r.Step != StepSwap {
|
||||
t.Errorf("a: %+v, want refused at %s", r, StepSwap)
|
||||
if r := result(o, "a"); r.Did != DidRefused || r.Step != StepNoStatus {
|
||||
t.Errorf("a: %+v, want refused at %s", r, StepNoStatus)
|
||||
}
|
||||
if r := result(o, "b"); r.Did != DidPutRight {
|
||||
t.Errorf("b: %+v, want put right", r)
|
||||
@@ -213,3 +214,50 @@ func TestOrganisationListRefusedIsSaid(t *testing.T) {
|
||||
t.Errorf("the refused list is not in the outcome: %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
// 0534-2 (the operator, 2026-10-23 ~12:05): the operator's own forge login stays on main's merge whitelist beside the
|
||||
// tools' account, so the operator can always merge by hand at the forge; a rule naming the tools' account alone is put
|
||||
// right, and a second pass changes nothing.
|
||||
func TestTheOperatorsLoginStaysBesideTheTools(t *testing.T) {
|
||||
f := newFake()
|
||||
repo := f.add("novox/mesh-delivery", map[string]map[string]any{mainRule: narrowedRule()}, "main")
|
||||
s, _ := syncer(t, f)
|
||||
s.Mergers = []string{"jschoubben", tools, "jschoubben"}
|
||||
o := run(t, s, Value{"mesh-delivery": {}, "fresh": {}})
|
||||
for name, rule := range map[string]map[string]any{"mesh-delivery": repo.Rules[mainRule], "fresh": f.repos["novox/fresh"].Rules[mainRule]} {
|
||||
if got := strings.Join(strs(rule["merge_whitelist_usernames"]), ","); got != tools+",jschoubben" {
|
||||
t.Errorf("%s's merge whitelist is %s, want %s and jschoubben", name, got, tools)
|
||||
}
|
||||
if !requiresNone(rule) || rule["enable_push"] != false {
|
||||
t.Errorf("%s's rule: %v", name, rule)
|
||||
}
|
||||
}
|
||||
if r := result(o, "mesh-delivery"); r.Did != DidPutRight {
|
||||
t.Errorf("outcome %+v, want put right", r)
|
||||
}
|
||||
before := len(f.writes())
|
||||
run(t, s, Value{"mesh-delivery": {}, "fresh": {}})
|
||||
if w := f.writes(); len(w) != before {
|
||||
t.Errorf("a second pass wrote %v", w[before:])
|
||||
}
|
||||
}
|
||||
|
||||
// 0534-2: the mergers are read from the module's runtime config file; a missing key is none; a name that is no forge login
|
||||
// is refused.
|
||||
func TestReadMergers(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "r.json")
|
||||
_ = os.WriteFile(p, []byte(`{"url":"","main-mergers":["jschoubben"]}`), 0o600)
|
||||
if m, err := ReadMergers(p); err != nil || strings.Join(m, ",") != "jschoubben" {
|
||||
t.Fatalf("%v %v", m, err)
|
||||
}
|
||||
_ = os.WriteFile(p, []byte(`{"url":""}`), 0o600)
|
||||
if m, err := ReadMergers(p); err != nil || len(m) != 0 {
|
||||
t.Fatalf("%v %v", m, err)
|
||||
}
|
||||
for _, bad := range []string{`["a b"]`, `["../x"]`, `[""]`, `"jschoubben"`} {
|
||||
_ = os.WriteFile(p, []byte(`{"main-mergers":`+bad+`}`), 0o600)
|
||||
if _, err := ReadMergers(p); err == nil {
|
||||
t.Errorf("%s was accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,8 @@ func lets(rule map[string]any, login string) bool {
|
||||
// and in the organisation the tools' team does (it is on every repository there); nobody else here.
|
||||
func reaches(full, login string) bool {
|
||||
owner := strings.SplitN(full, "/", 2)[0]
|
||||
return owner == login || (owner == org && login == tools)
|
||||
// The operator's own login is an owner of the organisation, so it reaches every repository there (ADR 0534 §2).
|
||||
return owner == login || (owner == org && (login == tools || login == "jschoubben"))
|
||||
}
|
||||
|
||||
// keepReaching drops from a rule's merge whitelist every user without access to the repository, as gitea's
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
// again whenever the value changes: it reads the file the mesh renders from that value and, with the setup login,
|
||||
// makes every listed repository that does not exist under that login's own account, protects its main, writes
|
||||
// main's first commit, narrows main's rule and only then moves it into the organisation; one that exists only has
|
||||
// its main rule put right, requiring mesh/check in place of the two statuses it replaces (issue 1301). A repository
|
||||
// the value does not list is touched only to swap those two for mesh/check when its main requires mesh/merge-gate. It keeps what it did, per
|
||||
// its main rule put right, requiring no status (novox/hq ADR 0534 §1). A repository the value does not list is
|
||||
// touched only to take the required statuses off a rule covering its main. It keeps what it did, per
|
||||
// repository, in the outcome file, says each change on stdout in one line and never a secret, and exits 1 when a
|
||||
// step was refused, its last stderr lines naming each (outcome.go Report): the node-engine's gate on a run-once
|
||||
// step (ADR 0136) reports it and runs it again on every apply until it passes.
|
||||
@@ -30,7 +30,8 @@ func main() {
|
||||
func runSync() int {
|
||||
forge, missing := forgehttp.FromEnv()
|
||||
file, outcome := os.Getenv("MESH_REPOSITORIES_FILE"), os.Getenv("MESH_REPOSITORIES_OUTCOME")
|
||||
for k, v := range map[string]string{"MESH_REPOSITORIES_FILE": file, "MESH_REPOSITORIES_OUTCOME": outcome} {
|
||||
for k, v := range map[string]string{"MESH_REPOSITORIES_FILE": file, "MESH_REPOSITORIES_OUTCOME": outcome,
|
||||
"MESH_GITEA_CONFIG_FILE": os.Getenv("MESH_GITEA_CONFIG_FILE")} {
|
||||
if v == "" {
|
||||
missing = append(missing, k)
|
||||
}
|
||||
@@ -56,6 +57,11 @@ func runSync() int {
|
||||
_ = WriteOutcome(outcome, o)
|
||||
return Report(o, os.Stderr)
|
||||
}
|
||||
if s.Mergers, err = ReadMergers(os.Getenv("MESH_GITEA_CONFIG_FILE")); err != nil {
|
||||
o := Outcome{At: time.Now().UTC(), Org: s.Org, Failed: err.Error()}
|
||||
_ = WriteOutcome(outcome, o)
|
||||
return Report(o, os.Stderr)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
o, _ := s.Sync(ctx, value) // a forge not reachable is in o.Failed
|
||||
|
||||
@@ -64,6 +64,7 @@ func TestManifestDeclaresTheValueAndItsTrigger(t *testing.T) {
|
||||
if v, err := ReadValue(filled); err != nil || len(v) != 1 || v["mesh-delivery"].Description != "d" {
|
||||
t.Errorf("the filled template reads as %v, %v; want mesh-delivery", v, err)
|
||||
}
|
||||
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "process" && has(strs(r["restart-on"]), file["id"].(string)) {
|
||||
process = r
|
||||
@@ -105,3 +106,61 @@ func TestManifestDeclaresTheValueAndItsTrigger(t *testing.T) {
|
||||
t.Errorf("repositories is declared unlike agent-read-teams: %v vs %v", v, teams)
|
||||
}
|
||||
}
|
||||
|
||||
// 0534-2: the manifest declares main-mergers as a trusted list naming the operator's login by default, merges it into
|
||||
// the module's runtime config file (with url, which the TypeScript part reads), and the repositories program is given
|
||||
// that file and runs again when it changes.
|
||||
func TestManifestDeclaresTheMainMergers(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Config map[string]map[string]any `json:"config"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v := m.Config[MergersKey]
|
||||
if v == nil || v["type"] != "list" || v["trusted"] != true || strings.Join(strs(v["default"]), ",") != "jschoubben" {
|
||||
t.Fatalf("config %s = %v, want a trusted list defaulting to the operator's login", MergersKey, v)
|
||||
}
|
||||
var cfg, process map[string]any
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == "runtime-config" {
|
||||
cfg = r
|
||||
}
|
||||
if r["name"] == "gitea-repositories" {
|
||||
process = r
|
||||
}
|
||||
}
|
||||
if cfg == nil || !has(strs(cfg["takes-config"]), MergersKey) || cfg["merge"] != "json" {
|
||||
t.Fatalf("runtime-config does not take %s: %v", MergersKey, cfg)
|
||||
}
|
||||
text, err := os.ReadFile("../../" + cfg["template"].(string))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := filepath.Join(t.TempDir(), "config.json")
|
||||
_ = os.WriteFile(file, text, 0o600)
|
||||
if got, err := ReadMergers(file); err != nil || strings.Join(got, ",") != "jschoubben" {
|
||||
t.Errorf("the template's mergers read as %v, %v; want the default", got, err)
|
||||
}
|
||||
env, _ := process["env"].(map[string]any)
|
||||
if env["MESH_GITEA_CONFIG_FILE"] != cfg["path"] {
|
||||
t.Errorf("the repositories program reads %v, want %v", env["MESH_GITEA_CONFIG_FILE"], cfg["path"])
|
||||
}
|
||||
if !has(strs(process["restart-on"]), "runtime-config") {
|
||||
t.Errorf("the repositories program does not run again when the mergers change: %v", process["restart-on"])
|
||||
}
|
||||
}
|
||||
|
||||
func has(list []string, want string) bool {
|
||||
for _, s := range list {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1141/AC1: a repository the program makes gets main's rule with an administrator's merge past a status refused,
|
||||
// 1141/AC1: a repository the program makes gets main's rule with an administrator's merge past it refused,
|
||||
// as every repository's main has had since 2026-10-07.
|
||||
func TestNewRepositoryRefusesTheAdministratorsOverride(t *testing.T) {
|
||||
f := newFake()
|
||||
@@ -18,7 +18,7 @@ func TestNewRepositoryRefusesTheAdministratorsOverride(t *testing.T) {
|
||||
}
|
||||
|
||||
// 1141/AC2: a narrowed main rule that still lets an administrator override is not right: it is put right, its
|
||||
// statuses kept (the two old ones as mesh/check, issue 1301), and a second pass changes nothing.
|
||||
// statuses taken off (ADR 0534 §1), and a second pass changes nothing.
|
||||
func TestNarrowedRuleLettingTheAdministratorOverrideIsPutRight(t *testing.T) {
|
||||
f := newFake()
|
||||
repo := f.add("novox/mesh-delivery", map[string]map[string]any{mainRule: {
|
||||
@@ -33,8 +33,8 @@ func TestNarrowedRuleLettingTheAdministratorOverrideIsPutRight(t *testing.T) {
|
||||
if rule["block_admin_merge_override"] != true {
|
||||
t.Errorf("the administrator's override is still allowed: %v", rule)
|
||||
}
|
||||
if c := strs(rule["status_check_contexts"]); strings.Join(c, ",") != "other/x,mesh/check" {
|
||||
t.Errorf("statuses %v, want other/x kept and mesh/check for the two it replaces (issue 1301)", c)
|
||||
if !requiresNone(rule) {
|
||||
t.Errorf("statuses %v, want none (ADR 0534 §1)", rule["status_check_contexts"])
|
||||
}
|
||||
if r := result(o, "mesh-delivery"); r.Did != DidPutRight {
|
||||
t.Errorf("outcome %+v, want put right", r)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -26,46 +27,11 @@ const (
|
||||
MainBranch = "main"
|
||||
)
|
||||
|
||||
// CheckStatus is the one status main requires (ADR 0447 §1, issue 1301): the checker's verdict on a pull request's
|
||||
// head merged onto its trunk's head. It replaces GateStatus and OwnCheckStatus, and is never dropped.
|
||||
const CheckStatus = "mesh/check"
|
||||
|
||||
// GateStatus and OwnCheckStatus are the two statuses CheckStatus replaces: the controller's merge gate and the
|
||||
// repository's own merge-check.sh (ADR 0238 §4, ADR 0428 §5 before ADR 0447 §1).
|
||||
const (
|
||||
GateStatus = "mesh/merge-gate"
|
||||
OwnCheckStatus = "mesh/repo-check"
|
||||
)
|
||||
|
||||
// wanted is what a main rule requiring have should require: have with GateStatus and OwnCheckStatus replaced by
|
||||
// CheckStatus, every other status kept in its order (ADR 0447 §1). A rule requiring OwnCheckStatus with neither
|
||||
// GateStatus nor CheckStatus is judged by its own script alone, as hq is until issue 1296 settles how hq is
|
||||
// checked (ADR 0503 §5): the checker writes no status there (ADR 0484 §8), so it is left as it is. This reads the
|
||||
// rule, not a list of names (lead decision L11 of R4e).
|
||||
func wanted(have []string) []string {
|
||||
if !has(have, GateStatus) && !has(have, CheckStatus) && has(have, OwnCheckStatus) {
|
||||
return have
|
||||
}
|
||||
out := []string{}
|
||||
for _, s := range have {
|
||||
if s != GateStatus && s != OwnCheckStatus && s != CheckStatus {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return append(out, CheckStatus)
|
||||
}
|
||||
|
||||
func has(list []string, want string) bool {
|
||||
for _, s := range list {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sameList(a, b []string) bool {
|
||||
return strings.Join(a, "\x00") == strings.Join(b, "\x00")
|
||||
// noStatus is what main's rule says of statuses: none is required to merge (novox/hq ADR 0534 §1, the operator,
|
||||
// 2026-10-23). The checks still run and report; mesh-delivery asks the operator's Approve on a passing verdict and the
|
||||
// merge tool judges it at the merge (ADR 0534 §3, §4). The forge enforces none of them.
|
||||
func noStatus() map[string]any {
|
||||
return map[string]any{"enable_status_check": false, "status_check_contexts": []string{}}
|
||||
}
|
||||
|
||||
// The steps of one repository, as an outcome names a refused one (ADR 0428 §6).
|
||||
@@ -82,8 +48,8 @@ const (
|
||||
StepRuleAfterMove = "rule-after-move"
|
||||
// StepList reads the organisation's repositories, for those the value does not list (issue 1301).
|
||||
StepList = "list"
|
||||
// StepSwap swaps the two old statuses for CheckStatus on a repository the value does not list (issue 1301).
|
||||
StepSwap = "swap"
|
||||
// StepNoStatus takes the required statuses off main's rule of a repository the value does not list (ADR 0534 §1).
|
||||
StepNoStatus = "no-status"
|
||||
)
|
||||
|
||||
// What a pass did with one repository.
|
||||
@@ -106,6 +72,37 @@ type Repo struct {
|
||||
// Value is the config value: each repository of the organisation by name.
|
||||
type Value map[string]Repo
|
||||
|
||||
// MergersKey is the config value naming the forge logins that merge into main beside the tools' account (ADR 0534 §2).
|
||||
const MergersKey = "main-mergers"
|
||||
|
||||
// loginName is a forge login: what a merge whitelist may name.
|
||||
var loginName = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,39}$`)
|
||||
|
||||
// ReadMergers reads the main-mergers value from the module's runtime config file, which the mesh merges the value into
|
||||
// (module.json runtime-config); a file without the key names none.
|
||||
func ReadMergers(file string) ([]string, error) {
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the module's config file cannot be read (%s): %w", file, err)
|
||||
}
|
||||
var whole map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &whole); err != nil {
|
||||
return nil, fmt.Errorf("the module's config file %s is not JSON: %w", file, err)
|
||||
}
|
||||
var out []string
|
||||
if r, ok := whole[MergersKey]; ok && string(r) != "null" {
|
||||
if err := json.Unmarshal(r, &out); err != nil {
|
||||
return nil, fmt.Errorf("%s in %s is not a list of forge logins: %w", MergersKey, file, err)
|
||||
}
|
||||
}
|
||||
for _, n := range out {
|
||||
if !loginName.MatchString(n) || strings.Contains(n, "..") {
|
||||
return nil, fmt.Errorf("%s in %s lists %q, which is not a forge login", MergersKey, file, n)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ReadValue reads the file the mesh renders from the config value. A file without the key is the empty value,
|
||||
// which makes nothing and touches nothing.
|
||||
func ReadValue(file string) (Value, error) {
|
||||
@@ -166,8 +163,11 @@ type Syncer struct {
|
||||
Forge forgehttp.Client // the setup login's
|
||||
Org string // the organisation, novox
|
||||
Admin string // the setup login, under whose own account a repository is made
|
||||
Tools string // the tools' account, the only one that merges into main
|
||||
Log func(format string, a ...any)
|
||||
Tools string // the tools' account, which merges into main for mesh-delivery
|
||||
// Mergers are the forge logins that merge into main beside the tools' account: the operator's own, so the operator
|
||||
// can always merge by hand at the forge (ADR 0534 §2; the gitea module's config value main-mergers).
|
||||
Mergers []string
|
||||
Log func(format string, a ...any)
|
||||
// WaitFor is how long to wait for the forge to answer; none when zero.
|
||||
WaitFor, Poll time.Duration
|
||||
}
|
||||
@@ -203,7 +203,7 @@ func (s *Syncer) Sync(ctx context.Context, v Value) (Outcome, error) {
|
||||
if r.Did == DidRefused {
|
||||
s.say("refused: %s/%s at the step %s: %s", s.Org, r.Name, r.Step, r.Said)
|
||||
} else {
|
||||
s.say("%s: %s/%s (%s in place of %s and %s)", r.Did, s.Org, r.Name, CheckStatus, GateStatus, OwnCheckStatus)
|
||||
s.say("%s: %s/%s (main requires no status)", r.Did, s.Org, r.Name)
|
||||
}
|
||||
out.Repositories = append(out.Repositories, r)
|
||||
}
|
||||
@@ -213,12 +213,10 @@ func (s *Syncer) Sync(ctx context.Context, v Value) (Outcome, error) {
|
||||
// orgPage is how many repositories one read of the organisation's list asks for.
|
||||
const orgPage = 50
|
||||
|
||||
// switchUnlisted swaps the two old statuses for CheckStatus on main's rule of every repository of the organisation
|
||||
// the value does not list whose main requires GateStatus (issue 1301, lead decisions L4 and L11 of R4e): the
|
||||
// repositories the merge gate judges are switched in the delivery that has the checker write mesh/check (ADR 0447
|
||||
// §5), with no list kept by hand. Only the statuses of a rule covering main are sent; nothing else of a rule the
|
||||
// value does not hold is changed (ADR 0428 §7 for all of it but the statuses the mesh itself requires). A main that
|
||||
// does not require GateStatus gets no call. It answers only what it did: a repository switched, or refused.
|
||||
// switchUnlisted takes every required status off a rule covering main in every repository of the organisation the
|
||||
// value does not list (ADR 0534 §1): the forge requires no status to merge anywhere, with no list kept by hand. Only
|
||||
// the statuses are sent; nothing else of a rule the value does not hold is changed (ADR 0428 §7). A main whose rules
|
||||
// require no status gets no call. It answers only what it did: a repository put right, or refused.
|
||||
func (s *Syncer) switchUnlisted(ctx context.Context, v Value) []Result {
|
||||
var names []string
|
||||
for page := 1; ; page++ {
|
||||
@@ -248,13 +246,12 @@ func (s *Syncer) switchUnlisted(ctx context.Context, v Value) []Result {
|
||||
}
|
||||
var did *Result
|
||||
for _, r := range rules {
|
||||
if ok, _ := path.Match(ruleName(r), MainBranch); !ok || !has(required(r), GateStatus) {
|
||||
if ok, _ := path.Match(ruleName(r), MainBranch); !ok || len(required(r)) == 0 {
|
||||
continue
|
||||
}
|
||||
body := map[string]any{"enable_status_check": true, "status_check_contexts": wanted(required(r))}
|
||||
if err := s.Forge.SendJSON(ctx, http.MethodPatch,
|
||||
repoAPI(s.Org, n)+"/branch_protections/"+url.PathEscape(ruleName(r)), body, nil); err != nil {
|
||||
did = &Result{Name: n, Did: DidRefused, Step: StepSwap, Said: err.Error()}
|
||||
repoAPI(s.Org, n)+"/branch_protections/"+url.PathEscape(ruleName(r)), noStatus(), nil); err != nil {
|
||||
did = &Result{Name: n, Did: DidRefused, Step: StepNoStatus, Said: err.Error()}
|
||||
break
|
||||
}
|
||||
did = &Result{Name: n, Did: DidPutRight}
|
||||
@@ -349,9 +346,9 @@ func (s *Syncer) one(ctx context.Context, name string, r Repo) Result {
|
||||
return refused(name, StepFirstCommit, err)
|
||||
}
|
||||
}
|
||||
if rule == nil || !isRight(rule, s.Tools) {
|
||||
if rule == nil || !isRight(rule, s.Tools, s.Mergers) {
|
||||
if err := s.Forge.SendJSON(ctx, http.MethodPatch, repoAPI(s.Admin, name)+"/branch_protections/"+MainBranch,
|
||||
narrowed(rule, s.Tools), nil); err != nil {
|
||||
narrowed(s.Tools, s.Mergers), nil); err != nil {
|
||||
return refused(name, StepNarrow, err)
|
||||
}
|
||||
}
|
||||
@@ -415,24 +412,6 @@ func strList(v any) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// statuses is what the rules require together, as wanted makes it.
|
||||
func statuses(rules ...rule) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
add := func(s string) {
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
for _, r := range rules {
|
||||
for _, s := range required(r) {
|
||||
add(s)
|
||||
}
|
||||
}
|
||||
return wanted(out)
|
||||
}
|
||||
|
||||
// required is what a rule requires: its statuses when it checks them, else none.
|
||||
func required(r rule) []string {
|
||||
if r == nil || r["enable_status_check"] != true {
|
||||
@@ -441,46 +420,58 @@ func required(r rule) []string {
|
||||
return strList(r["status_check_contexts"])
|
||||
}
|
||||
|
||||
// narrowed is main's rule of ADR 0398 §2 (protection.ts mainRuleBody): no push, only the tools' account merges,
|
||||
// requiring the statuses (ADR 0428 §5), and no administrator merging past them, as every repository's main has been
|
||||
// since 2026-10-07 (issue 1141). A field the forge keeps beside these is left as it is.
|
||||
func narrowed(r rule, toolsAccount string, also ...rule) map[string]any {
|
||||
return map[string]any{
|
||||
// narrowed is main's rule of ADR 0398 §2 (protection.ts mainRuleBody): no push, only the tools' account merges, no
|
||||
// administrator merging past the rule, as every repository's main has been since 2026-10-07 (issue 1141), and no
|
||||
// status required (ADR 0534 §1). A field the forge keeps beside these is left as it is.
|
||||
func narrowed(toolsAccount string, mergers []string) map[string]any {
|
||||
body := map[string]any{
|
||||
"enable_push": false, "enable_push_whitelist": false, "push_whitelist_usernames": []string{},
|
||||
"enable_merge_whitelist": true, "merge_whitelist_usernames": []string{toolsAccount},
|
||||
"enable_status_check": true, "status_check_contexts": statuses(append([]rule{r}, also...)...),
|
||||
"enable_merge_whitelist": true, "merge_whitelist_usernames": whitelist(toolsAccount, mergers),
|
||||
"block_admin_merge_override": true,
|
||||
}
|
||||
for k, v := range noStatus() {
|
||||
body[k] = v
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// isRight says whether a rule already is narrowed and requires what wanted makes of it.
|
||||
func isRight(r rule, toolsAccount string) bool {
|
||||
// whitelist is main's merge whitelist: the tools' account first, then each other merger once, in the value's order.
|
||||
func whitelist(toolsAccount string, mergers []string) []string {
|
||||
out := []string{toolsAccount}
|
||||
for _, m := range mergers {
|
||||
if !slices.Contains(out, m) {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isRight says whether a rule already is narrowed and requires no status.
|
||||
func isRight(r rule, toolsAccount string, mergers []string) bool {
|
||||
if r["enable_push"] != false || r["enable_push_whitelist"] == true || r["enable_merge_whitelist"] != true {
|
||||
return false
|
||||
}
|
||||
if m := strList(r["merge_whitelist_usernames"]); len(m) != 1 || m[0] != toolsAccount {
|
||||
if m := strList(r["merge_whitelist_usernames"]); !slices.Equal(m, whitelist(toolsAccount, mergers)) {
|
||||
return false
|
||||
}
|
||||
have := strList(r["status_check_contexts"])
|
||||
return r["enable_status_check"] == true && len(have) > 0 && sameList(have, wanted(have)) &&
|
||||
return r["enable_status_check"] != true && len(strList(r["status_check_contexts"])) == 0 &&
|
||||
r["block_admin_merge_override"] == true
|
||||
}
|
||||
|
||||
// putRight makes main's rule in an organisation's repository the narrowed one, keeping every status it requires but
|
||||
// the two CheckStatus replaces (ADR 0447 §1).
|
||||
// A repository with no rule named main gets one, carrying what a glob rule covering main asks (client.ts
|
||||
// ensureMainMerges, issue 948). It takes away no other status and never unprotects (ADR 0428 §5, §7).
|
||||
// putRight makes main's rule in an organisation's repository the narrowed one, requiring no status (ADR 0534 §1).
|
||||
// A repository with no rule named main gets one, carrying the approvals a glob rule covering main asks (client.ts
|
||||
// ensureMainMerges, issue 948). It never unprotects (ADR 0428 §7).
|
||||
func (s *Syncer) putRight(ctx context.Context, owner, name string) (bool, error) {
|
||||
rules, err := s.rules(ctx, owner, name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if own := ruleNamed(rules, MainBranch); own != nil {
|
||||
if isRight(own, s.Tools) {
|
||||
if isRight(own, s.Tools, s.Mergers) {
|
||||
return false, nil
|
||||
}
|
||||
return true, s.Forge.SendJSON(ctx, http.MethodPatch, repoAPI(owner, name)+"/branch_protections/"+MainBranch,
|
||||
narrowed(own, s.Tools), nil)
|
||||
narrowed(s.Tools, s.Mergers), nil)
|
||||
}
|
||||
var covering []rule
|
||||
for _, r := range rules {
|
||||
@@ -488,7 +479,7 @@ func (s *Syncer) putRight(ctx context.Context, owner, name string) (bool, error)
|
||||
covering = append(covering, r)
|
||||
}
|
||||
}
|
||||
body := narrowed(nil, s.Tools, covering...)
|
||||
body := narrowed(s.Tools, s.Mergers)
|
||||
body["rule_name"] = MainBranch
|
||||
approvals := 0.0
|
||||
for _, r := range covering {
|
||||
|
||||
@@ -19,7 +19,7 @@ const mainRule = "main"
|
||||
// 1124/AC2: a listed repository that does not exist is made under the setup account, given main's rule letting that
|
||||
// login alone push, given its first commit, narrowed to no push and the tools' merge requiring mesh/check (1124/AC5
|
||||
// as issue 1301 changes it), and only then moved into the organisation. Nothing reaches the organisation before the
|
||||
// narrowed rule, and nothing else is pushed to main.
|
||||
// narrowed rule, and nothing else is pushed to main. Main requires no status (ADR 0534 §1).
|
||||
func TestNewRepositoryIsMadeInOrder(t *testing.T) {
|
||||
f := newFake()
|
||||
s, _ := syncer(t, f)
|
||||
@@ -66,9 +66,8 @@ func TestNewRepositoryIsMadeInOrder(t *testing.T) {
|
||||
if rule["enable_merge_whitelist"] != true || strings.Join(strs(rule["merge_whitelist_usernames"]), ",") != tools {
|
||||
t.Errorf("main's merge whitelist is not the tools' account alone: %v", rule)
|
||||
}
|
||||
checks := strs(rule["status_check_contexts"])
|
||||
if rule["enable_status_check"] != true || strings.Join(checks, ",") != CheckStatus {
|
||||
t.Errorf("main does not require mesh/check alone (1124/AC5, issue 1301): %v", rule)
|
||||
if !requiresNone(rule) {
|
||||
t.Errorf("main requires a status (ADR 0534 §1): %v", rule)
|
||||
}
|
||||
if r := result(o, "mesh-delivery"); r.Did != DidMade || r.Step != "" {
|
||||
t.Errorf("outcome %+v, want made", r)
|
||||
@@ -87,7 +86,7 @@ func TestNewRepositoryIsMadeInOrder(t *testing.T) {
|
||||
|
||||
// 1124/AC2: the forge drops from a merge whitelist a user without access to the repository, and under the setup
|
||||
// account the tools' account has none; so once the repository is in the organisation its main rule is read back and
|
||||
// put right: the tools' account the only one that merges, mesh/check required.
|
||||
// put right: the tools' account the only one that merges, no status required (ADR 0534 §1).
|
||||
func TestOrganisationRuleListsTheToolsAfterTheMove(t *testing.T) {
|
||||
f := newFake()
|
||||
s, _ := syncer(t, f)
|
||||
@@ -103,9 +102,8 @@ func TestOrganisationRuleListsTheToolsAfterTheMove(t *testing.T) {
|
||||
if rule["enable_push"] != false {
|
||||
t.Errorf("after the move main takes a push: %v", rule)
|
||||
}
|
||||
checks := strs(rule["status_check_contexts"])
|
||||
if rule["enable_status_check"] != true || strings.Join(checks, ",") != CheckStatus {
|
||||
t.Errorf("after the move main does not require mesh/check alone: %v", rule)
|
||||
if !requiresNone(rule) {
|
||||
t.Errorf("after the move main requires a status (ADR 0534 §1): %v", rule)
|
||||
}
|
||||
if r := result(o, "mesh-delivery"); r.Did != DidMade {
|
||||
t.Errorf("outcome %+v, want made", r)
|
||||
@@ -136,8 +134,7 @@ func TestAdminRuleLetsTheSetupLoginAlonePush(t *testing.T) {
|
||||
}
|
||||
|
||||
// 1124/AC3: a listed repository that exists in the organisation is not made again, only its main rule put right;
|
||||
// a rule still letting the setup login push is narrowed, its statuses kept but the two mesh/check replaces (1124/AC5,
|
||||
// issue 1301).
|
||||
// a rule still letting the setup login push is narrowed, and requires no status (ADR 0534 §1).
|
||||
func TestExistingOrganisationRepositoryIsOnlyPutRight(t *testing.T) {
|
||||
f := newFake()
|
||||
hq := f.add("novox/hq", map[string]map[string]any{mainRule: {
|
||||
@@ -157,12 +154,11 @@ func TestExistingOrganisationRepositoryIsOnlyPutRight(t *testing.T) {
|
||||
if rule["enable_push"] != false || strings.Join(strs(rule["merge_whitelist_usernames"]), ",") != tools {
|
||||
t.Errorf("hq's rule not narrowed: %v", rule)
|
||||
}
|
||||
checks := strs(rule["status_check_contexts"])
|
||||
if strings.Join(checks, ",") != "other/x,mesh/check" {
|
||||
t.Errorf("hq's rule requires %v, want other/x and mesh/check", checks)
|
||||
if !requiresNone(rule) {
|
||||
t.Errorf("hq's rule requires %v, want none (ADR 0534 §1)", rule["status_check_contexts"])
|
||||
}
|
||||
made := bare.Rules[mainRule]
|
||||
if made == nil || made["enable_push"] != false || strings.Join(strs(made["status_check_contexts"]), ",") != CheckStatus {
|
||||
if made == nil || made["enable_push"] != false || !requiresNone(made) {
|
||||
t.Errorf("bare's rule: %v", made)
|
||||
}
|
||||
if hq.Commits != 0 || bare.Commits != 0 {
|
||||
@@ -212,33 +208,33 @@ func TestRepositoryUnderTheSetupAccountIsFinished(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1124/AC5 as issue 1301 changes it: putting a rule right adds mesh/check when the rule requires mesh/merge-gate,
|
||||
// and keeps every other status there; a rule requiring mesh/repo-check alone keeps it (ADR 0503 §5).
|
||||
func TestStatusesAreAddedAndNeverDropped(t *testing.T) {
|
||||
// ADR 0534 §1: putting a rule right takes every required status off it, whichever they are, and a rule already right
|
||||
// is not written.
|
||||
func TestStatusesAreTakenOff(t *testing.T) {
|
||||
f := newFake()
|
||||
only := f.add("novox/only", map[string]map[string]any{mainRule: {
|
||||
"enable_push": false, "enable_merge_whitelist": true, "merge_whitelist_usernames": []any{tools},
|
||||
"enable_status_check": true, "status_check_contexts": []any{"mesh/repo-check"},
|
||||
}}, "main")
|
||||
more := f.add("novox/more", map[string]map[string]any{mainRule: {
|
||||
right := f.add("novox/right", map[string]map[string]any{mainRule: {
|
||||
"enable_push": false, "enable_merge_whitelist": true, "merge_whitelist_usernames": []any{tools},
|
||||
"enable_status_check": true, "status_check_contexts": []any{"mesh/delivery", "mesh/check"},
|
||||
"enable_status_check": false, "status_check_contexts": []any{},
|
||||
"block_admin_merge_override": true, // right since issue 1141
|
||||
}}, "main")
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{"only": {}, "more": {}})
|
||||
if c := strs(only.Rules[mainRule]["status_check_contexts"]); strings.Join(c, ",") != "mesh/repo-check" {
|
||||
t.Errorf("only: %v", c)
|
||||
o := run(t, s, Value{"only": {}, "right": {}})
|
||||
if !requiresNone(only.Rules[mainRule]) {
|
||||
t.Errorf("only: %v", only.Rules[mainRule]["status_check_contexts"])
|
||||
}
|
||||
if c := strs(more.Rules[mainRule]["status_check_contexts"]); len(c) != 2 || !has(c, "mesh/delivery") {
|
||||
t.Errorf("more lost a status: %v", c)
|
||||
if !requiresNone(right.Rules[mainRule]) {
|
||||
t.Errorf("right: %v", right.Rules[mainRule]["status_check_contexts"])
|
||||
}
|
||||
for _, w := range f.writes() {
|
||||
if strings.Contains(w, "/more/") {
|
||||
if strings.Contains(w, "/right/") {
|
||||
t.Errorf("a right rule was written: %s", w)
|
||||
}
|
||||
}
|
||||
if result(o, "more").Did != DidUnchanged || result(o, "only").Did != DidPutRight {
|
||||
if result(o, "right").Did != DidUnchanged || result(o, "only").Did != DidPutRight {
|
||||
t.Errorf("outcome %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,10 +101,10 @@ export function deliveryStatus(context: string, state: string, description: stri
|
||||
if (!/^mesh\/[a-z-]+$/.test(context)) throw new Error(`${context} is not a status of the mesh's own`);
|
||||
if (!states.has(state)) throw new Error(`${state} is not a status the forge keeps`);
|
||||
// mesh/check is the checker's verdict and the one status main requires (ADR 0447 §1): no hand sets it in any
|
||||
// state, as issue 293 closed for the two before it. The forge bridge writes it through gitea_check_status, a tool
|
||||
// only its account may call (issue 1301).
|
||||
// state, as issue 293 closed for the two before it. No tool writes it, since a tool is told no caller (R4e finding K1):
|
||||
// check.ts writes it from the checker's owed state (ADR 0521).
|
||||
if (context === CHECK_STATUS) {
|
||||
throw new Error(`${CHECK_STATUS} is the checker's status: the forge bridge writes it through gitea_check_status, never this tool`);
|
||||
throw new Error(`${CHECK_STATUS} is the checker's status: it is written from mesh-delivery-checker.owed alone, never by a tool`);
|
||||
}
|
||||
// The merge check's statuses are its verdict's, set when the controller says it, and a branch may require
|
||||
// them: never set to a verdict by hand, so nothing passes — or fails — a merge past the check (novox/hq
|
||||
@@ -136,7 +136,7 @@ export interface StatusWriters {
|
||||
export function commitStatusTool(writers: StatusWriters): ToolDefinition {
|
||||
return {
|
||||
name: "gitea_commit_status",
|
||||
description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to. mesh/check, the status main requires, is refused: the checker's forge bridge writes it through gitea_check_status. hq's mesh/repo-check (and mesh/merge-gate, kept for a revert) take pending only — a recheck putting them back until the fresh verdict sets them — on the head's whole sha, in their turn after the check's earlier writes.",
|
||||
description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to. mesh/check, the status main requires, is refused: no tool writes it: it is written from the checker's own state. hq's mesh/repo-check (and mesh/merge-gate, kept for a revert) take pending only — a recheck putting them back until the fresh verdict sets them — on the head's whole sha, in their turn after the check's earlier writes.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
@@ -163,48 +163,3 @@ export function commitStatusTool(writers: StatusWriters): ToolDefinition {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** What the check's status tool writes with: the forge. */
|
||||
export interface CheckStatusWriter {
|
||||
setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `gitea_check_status` tool (novox/hq issue 1301, ADR 0447 §1): the one way `mesh/check` is written. Its arguments
|
||||
* are gitea_commit_status's, so the forge bridge sends an owed status's arguments unchanged to it: owner, repo, sha
|
||||
* (the head's whole sha), state, description, target_url, and context, which may be left out and is mesh/check when
|
||||
* given. **Who may write it is the bus's grant**: the runtime tells a tool no caller, so only a module that lists
|
||||
* `gitea.gitea_check_status` in its manifest's `invokes` may call it, and the forge bridge alone does. Served beside
|
||||
* gitea_commit_status, from the watcher's bundle.
|
||||
*/
|
||||
export function checkStatusTool(writer: CheckStatusWriter): ToolDefinition {
|
||||
return {
|
||||
name: "gitea_check_status",
|
||||
description: "Set mesh/check, the checker's verdict and the one status main requires, on a pull request's head: pending, success, error, failure or warning, a short description, and the page it links to. The checker's forge bridge alone may call it (its manifest's invokes); no other status is written here, and no other tool writes this one.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
sha: { type: "string", description: "the head's whole sha" },
|
||||
context: { type: "string", description: "mesh/check, or left out" },
|
||||
state: { type: "string", description: "pending | success | error | failure | warning" },
|
||||
description: { type: "string", description: "one line" },
|
||||
target_url: { type: "string", description: "the page it links to (the check's report)" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const owner = checkName("owner", args.owner), repo = checkName("name", args.repo), sha = String(args.sha ?? "");
|
||||
if (args.context !== undefined && args.context !== null && String(args.context) !== CHECK_STATUS) {
|
||||
throw new Error(`${JSON.stringify(args.context)}: this tool writes ${CHECK_STATUS} alone. Nothing was sent`);
|
||||
}
|
||||
if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha)) {
|
||||
throw new Error(`${JSON.stringify(args.sha)} is not a head's whole sha: ${CHECK_STATUS} names the head whole. Nothing was sent`);
|
||||
}
|
||||
// deliveryStatus checks the state and cuts the description; it refuses mesh/check, so it is asked for a
|
||||
// context of the mesh's own and the status is named mesh/check after.
|
||||
const checked = deliveryStatus("mesh/delivery", String(args.state), String(args.description ?? ""),
|
||||
args.target_url ? String(args.target_url) : undefined);
|
||||
const status = { ...checked, context: CHECK_STATUS };
|
||||
await writer.setCommitStatus(owner, repo, sha, status);
|
||||
return { set: status };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{}
|
||||
{"main-mergers": ["jschoubben"]}
|
||||
|
||||
+16
-18
@@ -10,15 +10,16 @@
|
||||
// Everything else — administration, protection, settings, collaborators, teams, hooks, keys, tokens,
|
||||
// statuses, a merge, a repository deleted or moved — has its own tool or is the operator's, at the
|
||||
// forge as themselves;
|
||||
// - `gitea_branch_protection_set` only makes protection stronger;
|
||||
// - `gitea_merge_pull_request` merges only a head whose every required status succeeded, and only that
|
||||
// head;
|
||||
// - `gitea_branch_protection_set` lets no person push and no administrator merge past a rule, and on main keeps
|
||||
// the tools' account the only one that merges; it requires no status, as no rule of the mesh does (ADR 0534 §5);
|
||||
// - `gitea_merge_pull_request` merges only into a branch that refuses its push, only a head whose mesh/check passed
|
||||
// against the trunk's head, approved by the operator where the branch requires no status (ADR 0534 §4), and only
|
||||
// that head;
|
||||
// - `gitea_delete_repo` refuses a repository any protection rule guards.
|
||||
//
|
||||
// Pure, so it is tested without a forge; the tools do the asking (tools/index.ts).
|
||||
|
||||
import { MAIN_BRANCH, type BranchProtection, type ProtectionWanted } from "./protection.js";
|
||||
import { TOOLS_ACCOUNT } from "./tools-account.js";
|
||||
import { MAIN_BRANCH, mainMergers, type BranchProtection, type ProtectionWanted } from "./protection.js";
|
||||
|
||||
/** A refusal of policy, in the words the caller sees. Never an error of the forge's. */
|
||||
export class Refused extends Error {
|
||||
@@ -163,7 +164,7 @@ export function judgeApiCall(method: string, rawPath: string, body: unknown): Ap
|
||||
`that is not the default branch and that no protection rule names. The forge's administration, a ` +
|
||||
`branch's protection, a repository's settings, collaborators, teams, hooks, keys, tokens and statuses ` +
|
||||
`are the operator's at the forge, or have their own tool (gitea_merge_pull_request, gitea_commit_status, ` +
|
||||
`gitea_branch_protection_set, which only strengthens). Nothing was sent`,
|
||||
`gitea_branch_protection_set, which never lets a person push). Nothing was sent`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -287,7 +288,7 @@ export function judgeContentsWrite(target: string, defaultBranch: string, rules:
|
||||
* Refuses a protection change that weakens what guards a branch (novox/hq ADR 0266): the branch's own rule, or —
|
||||
* when it has none — every glob rule that covers it, since a rule named for the branch takes the glob's place.
|
||||
* A rule made new takes the forge's defaults for what it does not say: pushes refused, an administrator's
|
||||
* override allowed.
|
||||
* override allowed. A required status taken away is no weakening: no rule of the mesh requires one (ADR 0534 §5).
|
||||
*/
|
||||
export function judgeProtectionChange(before: BranchProtection | null, want: ProtectionWanted,
|
||||
covering: BranchProtection[] = [], branch = ""): string | null {
|
||||
@@ -305,37 +306,34 @@ export function judgeProtectionChange(before: BranchProtection | null, want: Pro
|
||||
}
|
||||
return null; // A new rule protects what nothing protected.
|
||||
}
|
||||
const had = before.enable_status_check ? [...new Set((before.status_check_contexts ?? []).filter(Boolean))] : [];
|
||||
const wanted = new Set(want.statusChecks.map((c) => c.trim()).filter(Boolean));
|
||||
const dropped = had.filter((c) => !wanted.has(c));
|
||||
const why: string[] = [];
|
||||
if (dropped.length) why.push(`it drops the required status ${dropped.join(", ")}`);
|
||||
if (want.push === true && before.enable_push === false) why.push("it lets a person push to the branch directly");
|
||||
if (want.blockAdminOverride === false && before.block_admin_merge_override === true) {
|
||||
why.push("it lets an administrator merge past a status that has not succeeded");
|
||||
why.push("it lets an administrator merge past what the rule asks");
|
||||
}
|
||||
if (!why.length) return null;
|
||||
return `the protection of ${before.rule_name ?? before.branch_name} is only made stronger through a tool: ` +
|
||||
`${why.join("; ")}. Whoever may call a tool includes agents, and a branch's protection is what keeps a ` +
|
||||
`change from merging without its checks and the operator (novox/hq ADR 0266) — weakening it is the ` +
|
||||
`change from landing but through a pull request the operator approved (novox/hq ADR 0266, ADR 0534) — weakening it is the ` +
|
||||
`operator's, at the forge as themselves. Nothing was changed`;
|
||||
}
|
||||
|
||||
/** Main's rule takes no push and lets only the tools' account merge (novox/hq ADR 0398 §2, issue 948): a change
|
||||
* that enables push, drops the merge whitelist or widens it past that account is refused, whatever the rule was. */
|
||||
/** Main's rule takes no push and lets only the tools' account and main-mergers merge (novox/hq ADR 0398 §2, issue 948,
|
||||
* ADR 0534 §2): a change that enables push, drops the merge whitelist or widens it past them is refused, whatever the
|
||||
* rule was. */
|
||||
function judgeMain(before: BranchProtection | null, want: ProtectionWanted): string | null {
|
||||
const why: string[] = [];
|
||||
if (want.push === true) why.push("it lets a person push to the branch directly");
|
||||
if (want.mergeWhitelist !== undefined) {
|
||||
const names = want.mergeWhitelist ?? [];
|
||||
if (names.length === 0) why.push("it drops the merge whitelist of main");
|
||||
else if (names.some((n) => n !== TOOLS_ACCOUNT)) {
|
||||
why.push(`it widens who may merge into main beyond ${TOOLS_ACCOUNT}`);
|
||||
else if (names.some((n) => !mainMergers().includes(n))) {
|
||||
why.push(`it widens who may merge into main beyond ${mainMergers().join(", ")}`);
|
||||
}
|
||||
}
|
||||
if (!why.length) return null;
|
||||
return `the protection of ${before?.rule_name ?? before?.branch_name ?? "main"} is only made stronger through a tool: ` +
|
||||
`${why.join("; ")}. Main takes no push, and only ${TOOLS_ACCOUNT} merges into it (novox/hq ADR 0398 §2) — ` +
|
||||
`${why.join("; ")}. Main takes no push, and only ${mainMergers().join(" and ")} merge into it (novox/hq ADR 0398 §2, ADR 0534 §2) — ` +
|
||||
`weakening that is the operator's, at the forge as themselves. Nothing was changed`;
|
||||
}
|
||||
|
||||
|
||||
+17
-5
@@ -13,8 +13,12 @@
|
||||
// commit's statuses: `mesh/merge-gate`, the modules of the mesh's graph the change touches, and
|
||||
// `mesh/repo-check`, the repository's own merge-check.sh (novox/hq ADR 0237 as amended); with a comment
|
||||
// saying why when the gate is not a pass or the repository's own check failed. Of the two, only those the base
|
||||
// still requires are written (issue 1301): main requires `mesh/check` — the checker's, written by its forge bridge
|
||||
// through gitea_check_status — everywhere but hq, whose main requires `mesh/repo-check` alone (ADR 0503 §5).
|
||||
// still requires are written (issue 1301): main requires `mesh/check` — the checker's — everywhere but hq, whose main
|
||||
// requires `mesh/repo-check` alone (ADR 0503 §5).
|
||||
//
|
||||
// Reads mesh-delivery-checker.owed — what the checker owes the forge, mesh/check on each head it judged — by a watch, and
|
||||
// writes it (check.ts; ADR 0521, R4e finding R2-1). No tool writes mesh/check (finding K1), and no event: the watch hands
|
||||
// over the latest value of every key whenever this module starts.
|
||||
//
|
||||
// Consumes node-build-agent.started — a build machine took an ask — and says, on the head a pending check is of,
|
||||
// which machine builds it since when (novox/hq issue 446). This module is the one writer of mesh/merge-gate.
|
||||
@@ -41,7 +45,9 @@
|
||||
import { emit, on } from "@novox/mesh-sdk/events";
|
||||
import { registerModuleTools } from "@novox/mesh-sdk/tools";
|
||||
import { statesAnnouncement } from "./kept.js";
|
||||
import { checkStatusTool, commitStatusTool } from "./delivery.js";
|
||||
import { commitStatusTool } from "./delivery.js";
|
||||
import { CHECKER_OWED, CheckStatuses, watchOwed, type OwedChange } from "./check.js";
|
||||
import { state } from "@novox/mesh-sdk/state";
|
||||
import { announce, keepWatermark, readWatermark } from "./announce.js";
|
||||
import { mergeTool } from "./merge.js";
|
||||
import { GiteaAdmin, GiteaClient, movedSince } from "./client.js";
|
||||
@@ -475,6 +481,11 @@ const gates = new GateWriter({
|
||||
headChecks: (owner, repo, sha) => (gitea as GiteaClient).headChecks(owner, repo, sha),
|
||||
});
|
||||
|
||||
// The one writer of mesh/check, from the checker's owed (check.ts; ADR 0521).
|
||||
const checks = new CheckStatuses({
|
||||
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
|
||||
});
|
||||
|
||||
// **The status tool is served here, beside the writer** (novox/hq issue 456): the runtime starts each bundle as its own
|
||||
// process, so a recheck's pending written from the tools' bundle bypassed this writer's chain. Without a forge to
|
||||
// reach, nothing is served, as the tools' bundle serves nothing then.
|
||||
@@ -491,8 +502,6 @@ await emit("states", statesAnnouncement(process.env.MESH_MODULE || "gitea", proc
|
||||
registerModuleTools("gitea", () => [eventsTool(pace), ...(gitea ? [commitStatusTool({
|
||||
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
|
||||
gates,
|
||||
}), checkStatusTool({
|
||||
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
|
||||
}), mergeTool(gitea, { gates })] : [])]);
|
||||
|
||||
// **A build machine took an ask** (the build seat's `started`, novox/hq ADR 0157): said on the head it checks, when
|
||||
@@ -621,4 +630,7 @@ if (gitea) {
|
||||
})],
|
||||
["node-build-agent.started", () => on("node-build-agent.started", buildStarted)],
|
||||
], () => console.log("[gitea] watching for new repositories and merged pull requests"));
|
||||
// The checker's mesh/check, from its owed state (check.ts; ADR 0521): a read not granted yet is said once and asked again,
|
||||
// apart from the binds above, so neither waits for the other.
|
||||
void watchOwed((handler) => state<OwedChange["value"]>(CHECKER_OWED).watch((change) => handler(change)), checks);
|
||||
}
|
||||
|
||||
+31
-16
@@ -5,10 +5,12 @@
|
||||
// puts its statuses back to pending through that writer, so where the check runs is said and no second check is asked.
|
||||
// Each bundle the runtime serves is its own process; from the tools' bundle the pending bypassed the writer's chain.
|
||||
//
|
||||
// **It judges on the status the base requires** (issue 1301; ADR 0447 §1, §4; ADR 0503 §5): mesh/check, the checker's,
|
||||
// whose line names the trunk it judged ("pass <head12> on <trunk12>"); where a base does not require it, the controller's
|
||||
// status it still requires (hq's mesh/repo-check), as before. A mesh/check of another trunk is refused and nothing is
|
||||
// asked: the checker sets it out of date on the trunk's move and checks it again.
|
||||
// **It judges mesh/check whether or not the base requires it** (issue 1301; ADR 0447 §1, §4; ADR 0534 §4): the checker's
|
||||
// verdict, whose line names the trunk it judged ("pass <head12> on <trunk12>"), must be a pass against the trunk's head.
|
||||
// The forge requires no status to merge (ADR 0534 §1); where a base still requires one of the controller's old statuses,
|
||||
// until the forge's holder takes it off, that status judges, as before. A mesh/check of another trunk is refused and
|
||||
// nothing is asked: the checker sets it out of date on the trunk's move and checks it again. Where the base requires no
|
||||
// status, only a head the operator's Approve names (approved_heads) merges (ADR 0534 §4, ADR 0421 §9).
|
||||
|
||||
import { announce } from "./announce.js";
|
||||
import type { ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
@@ -36,9 +38,10 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
|
||||
now: given.now ?? (() => new Date()) };
|
||||
return {
|
||||
name: "gitea_merge_pull_request",
|
||||
description: "Merge a pull request at the head you name — the commit you reviewed — when that is still its head " +
|
||||
"and every status its base branch's protection requires succeeded there (mesh/check; hq's mesh/repo-check), " +
|
||||
"judged against the trunk's head as it is now, optionally deleting the source branch afterwards. Refused " +
|
||||
description: "Merge a pull request at the head you name — the commit you reviewed — when that is still its head, " +
|
||||
"its base refuses this account's push, its mesh/check passed judged against the trunk's head as it is now, and, " +
|
||||
"where the base requires no status (novox/hq ADR 0534), the operator's Approve names it in approved_heads; " +
|
||||
"optionally deleting the source branch afterwards. Refused " +
|
||||
"otherwise, with nothing merged: no tool merges past a check, and none merges a head that moved after it was " +
|
||||
"reviewed. A verdict judged against an earlier trunk commit is refused, naming both, and the head's check against " +
|
||||
"the trunk's head is asked once, with how long the last check of the repository took: ask again at the same head " +
|
||||
@@ -100,11 +103,11 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
|
||||
`approved (novox/hq ADR 0421). Nothing was merged`);
|
||||
}
|
||||
const rule = await gitea.appliedProtection(owner, repo, pull.base);
|
||||
// Only into a branch whose protection requires the checks: a pull request whose base was moved to a
|
||||
// branch nothing guards would merge past every check (novox/hq ADR 0266).
|
||||
if (!rule || !rule.enable_status_check || !(rule.status_check_contexts ?? []).filter(Boolean).length) {
|
||||
throw new Refused(`${owner}/${repo}#${number} merges into ${pull.base}, which no protection rule requiring ` +
|
||||
`the checks guards: a tool merges only into such a branch (novox/hq ADR 0266). Nothing was merged`);
|
||||
// Only into a branch whose protection refuses this account's push: a pull request whose base was moved to a
|
||||
// branch nothing guards would land where anyone pushes (novox/hq ADR 0266, ADR 0534 §4).
|
||||
if (!rule || rule.enable_push !== false) {
|
||||
throw new Refused(`${owner}/${repo}#${number} merges into ${pull.base}, which no protection rule refusing ` +
|
||||
`a push guards: a tool merges only into such a branch (novox/hq ADR 0266, ADR 0534). Nothing was merged`);
|
||||
}
|
||||
// **The verdict must be judged against the trunk's head** (novox/hq ADR 0323 §5, issue 341): a check judges the
|
||||
// head merged into a named trunk commit, and a merge of another trunk is not the one that check judged. Compared
|
||||
@@ -114,7 +117,16 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
|
||||
const trunkHead = await gitea.branchHead(owner, repo, pull.base);
|
||||
const checks = await gitea.headChecks(owner, repo, pull.head_sha);
|
||||
const required = requiredOf(rule) ?? [];
|
||||
if (required.includes(CHECK_STATUS)) {
|
||||
// **The forge requires no status; the operator's Approve and mesh/check do** (ADR 0534 §4): where the base requires
|
||||
// no status, only a head the Approve names merges, and mesh/check judges it as where it was required.
|
||||
if (required.length === 0 && !approved) {
|
||||
throw new Refused(`${owner}/${repo}#${number} is not merged: ${pull.base} requires no status, so a merge into it ` +
|
||||
`lands only a head the operator's Approve names: give approved_heads with ${owner}/${repo}@<full sha> ` +
|
||||
`(novox/hq ADR 0534, ADR 0421 §9). Nothing was merged`);
|
||||
}
|
||||
const kept = maintained(required);
|
||||
const byCheck = required.includes(CHECK_STATUS) || kept.length === 0;
|
||||
if (byCheck) {
|
||||
// **mesh/check is the checker's** (ADR 0447 §4): it is judged here against the trunk's head, and refused when it
|
||||
// is not current; nothing is written or asked — the checker checks a head again when its trunk moves.
|
||||
const check = checks[CHECK_STATUS];
|
||||
@@ -124,6 +136,10 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
|
||||
`${check ? `pending (${check.description ?? ""})` : "not set yet"}; the checker answers it. Ask again at ` +
|
||||
`${twelve(pull.head_sha)} once it passes against ${pull.base}'s head ${twelve(trunkHead)}. Nothing was merged`);
|
||||
}
|
||||
if (check.state !== "success") {
|
||||
throw new Refused(`${owner}/${repo}#${number} is not merged: its ${CHECK_STATUS} is ${check.state} ` +
|
||||
`(${check.description ?? ""}). A pull request merges once its check passes (novox/hq ADR 0534). Nothing was merged`);
|
||||
}
|
||||
if (!at.current) {
|
||||
throw new Refused(`${owner}/${repo}#${number} is not merged: its ${CHECK_STATUS} ` +
|
||||
`${at.judged ? `was judged on trunk ${twelve(at.judged)}` : "names no trunk commit it was judged on"}, and ` +
|
||||
@@ -133,7 +149,6 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
|
||||
}
|
||||
// Where the base still requires the controller's statuses (hq's mesh/repo-check; mesh/merge-gate only on a
|
||||
// revert), the first it requires judges, as before (ADR 0323 §5).
|
||||
const kept = maintained(required);
|
||||
const judge = kept[0] ?? CHECK_CONTEXT;
|
||||
const at = kept.length ? verdictAtTrunk(checks, trunkHead, judge) : { current: true, onItsWay: false };
|
||||
if (!at.current) {
|
||||
@@ -197,9 +212,9 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
|
||||
// of them is no verdict to merge on, never read as none.
|
||||
// Under mesh/check (issue 1301) the group is the checker's one verdict on every member's head (ADR 0447 §2) and the
|
||||
// Approve's heads above; a record the controller's verdicts left before the switch judges nothing there.
|
||||
const heard = required.includes(CHECK_STATUS) ? undefined : readSiblings(siblingsFile(deps.stateDir))[memberKey(owner, repo, number)];
|
||||
const heard = byCheck ? undefined : readSiblings(siblingsFile(deps.stateDir))[memberKey(owner, repo, number)];
|
||||
const siblings = heard && sameCommit(heard.head, pull.head_sha) ? heard.siblings : [];
|
||||
if (!siblings.length && !required.includes(CHECK_STATUS) &&
|
||||
if (!siblings.length && !byCheck &&
|
||||
namesSiblings(statuses.find((s) => s.context === CHECK_CONTEXT)?.description)) {
|
||||
throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict read siblings, and which ones is not held ` +
|
||||
`here, so the group cannot be weighed. Ask its check again at ${twelve(pull.head_sha)}. Nothing was merged`);
|
||||
|
||||
@@ -172,9 +172,15 @@
|
||||
},
|
||||
"repositories": {
|
||||
"type": "object",
|
||||
"description": "The repositories of the organisation novox the mesh holds (ADR 0428): each by name, {\"<name>\": {\"private\": true, \"description\": \"...\"}}. One that does not exist is made by the module's setup login under its own account, its main protected and given a first commit (a README naming the description and a merge-check.sh), its rule narrowed to no push and the tools' merge requiring mesh/check, and only then moved into the organisation; one that exists only has main's rule put right, mesh/check in place of mesh/merge-gate and mesh/repo-check and its other statuses kept (a main requiring mesh/repo-check alone keeps it). Every other repository whose main requires mesh/merge-gate has the two swapped for mesh/check; nothing else of a repository out of the value is touched.",
|
||||
"description": "The repositories of the organisation novox the mesh holds (ADR 0428): each by name, {\"<name>\": {\"private\": true, \"description\": \"...\"}}. One that does not exist is made by the module's setup login under its own account, its main protected and given a first commit (a README naming the description and a merge-check.sh), its rule narrowed to no push, the tools' account and main-mergers alone merging, and no status required (ADR 0534), and only then moved into the organisation; one that exists only has main's rule put right so. Every other repository has the statuses its main requires taken off; nothing else of a repository out of the value is touched.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"main-mergers": {
|
||||
"type": "list",
|
||||
"description": "The forge logins that may merge into main beside the tools' account, on every repository of the organisation (ADR 0534 §2): the operator's own, so the operator can always merge by hand at the forge. The tools' account is always on main's merge whitelist; no agent's account belongs here.",
|
||||
"trusted": true,
|
||||
"default": ["jschoubben"]
|
||||
}
|
||||
},
|
||||
"facts": {
|
||||
@@ -303,7 +309,8 @@
|
||||
"template": "files/config.json",
|
||||
"merge": "json",
|
||||
"takes-config": [
|
||||
"url"
|
||||
"url",
|
||||
"main-mergers"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -366,10 +373,12 @@
|
||||
"MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret",
|
||||
"MESH_GITEA_TOOLS_USER": "mesh-tools",
|
||||
"MESH_REPOSITORIES_FILE": "${dir:mesh-state}/repositories.json",
|
||||
"MESH_REPOSITORIES_OUTCOME": "${dir:runtime-state}/repositories-outcome.json"
|
||||
"MESH_REPOSITORIES_OUTCOME": "${dir:runtime-state}/repositories-outcome.json",
|
||||
"MESH_GITEA_CONFIG_FILE": "${dir:mesh-state}/config.json"
|
||||
},
|
||||
"restart-on": [
|
||||
"repositories-file"
|
||||
"repositories-file",
|
||||
"runtime-config"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -543,6 +552,7 @@
|
||||
"ssh-client.keys",
|
||||
"mesh-build-go.keys",
|
||||
"mesh-build-node.keys",
|
||||
"mesh-build-image.keys"
|
||||
"mesh-build-image.keys",
|
||||
"mesh-delivery-checker.owed"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc client.ts token.ts index.ts provisioner/index.ts provisioner/teams.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
|
||||
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
|
||||
"test": "npm run build && tsc --module NodeNext --moduleResolution NodeNext --target ES2022 --types node --rootDir . --outDir . $(find . -name '*.ts' ! -name '*.d.ts' ! -path './test/*' ! -path './node_modules/*' ! -path './dist/*') && node --test --experimental-strip-types 'test/*.test.ts'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.1"
|
||||
|
||||
+37
-11
@@ -1,7 +1,9 @@
|
||||
// A branch's protection (novox/hq ADR 0237): what makes a pull request wait for the mesh's check — `mesh/check`,
|
||||
// the checker's verdict on the head merged onto its trunk (ADR 0447 §1; hq's `mesh/repo-check` until issue 1296) —
|
||||
// before it may merge. Pure, so it is tested without a forge; the client does the asking (client.ts).
|
||||
// A branch's protection (novox/hq ADR 0237): no direct push, and on main only the tools' account merges (ADR 0398 §2).
|
||||
// The forge requires no status to merge (ADR 0534 §1): the checks still run and report, and mesh-delivery and the
|
||||
// merge tool judge them (ADR 0534 §3, §4), never the forge. Pure, so it is tested without a forge; the client does the
|
||||
// asking (client.ts).
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { TOOLS_ACCOUNT } from "./tools-account.js";
|
||||
|
||||
/** The branch whose rule takes no push and lets only the tools' account merge (novox/hq ADR 0398 §2, issue 948). */
|
||||
@@ -24,26 +26,50 @@ export interface BranchProtection {
|
||||
/** What a branch's protection is set to. A field not given is left as the rule has it (or the forge's
|
||||
* default on a new rule), except pushes, which a new rule refuses unless `push` says otherwise. */
|
||||
export interface ProtectionWanted {
|
||||
/** The statuses a pull request must have succeeded before it merges, e.g. mesh/check. Empty: none. */
|
||||
statusChecks: string[];
|
||||
/** Whether a person may push to the branch directly. */
|
||||
push?: boolean;
|
||||
/** Whether an administrator is stopped from merging past a status that has not succeeded. */
|
||||
/** Whether an administrator is stopped from merging past what the rule asks. */
|
||||
blockAdminOverride?: boolean;
|
||||
/** Who may merge into the branch; for main only ever the tools' account (ADR 0398 §2). Not given: left as it is.
|
||||
* Empty or null asks for no whitelist, which the guard refuses for main; the body for main ignores it. */
|
||||
mergeWhitelist?: string[] | null;
|
||||
}
|
||||
|
||||
/** What main's rule always says: no push, and only the tools' account merges (novox/hq ADR 0398 §2, issue 948). */
|
||||
export function mainRuleBody(): Record<string, unknown> {
|
||||
return { enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: [TOOLS_ACCOUNT] };
|
||||
/** What every rule the mesh writes says of statuses: none is required to merge (novox/hq ADR 0534 §1). */
|
||||
export function noStatusBody(): Record<string, unknown> {
|
||||
return { enable_status_check: false, status_check_contexts: [] };
|
||||
}
|
||||
|
||||
/** A forge login a merge whitelist may name. */
|
||||
const LOGIN = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,39}$/;
|
||||
|
||||
/** Who merges into main: the tools' account, for mesh-delivery, then each login of the module's config value
|
||||
* main-mergers once, the operator's own, so the operator can always merge by hand at the forge (novox/hq ADR 0534 §2).
|
||||
* Read from the module's runtime config file (MESH_GITEA_CONFIG_FILE); a file not there, or a name that is no login,
|
||||
* leaves the tools' account alone, the narrowest whitelist. */
|
||||
export function mainMergers(env: NodeJS.ProcessEnv = process.env): string[] {
|
||||
const out = [TOOLS_ACCOUNT];
|
||||
let given: unknown = [];
|
||||
try {
|
||||
if (env.MESH_GITEA_CONFIG_FILE) given = JSON.parse(readFileSync(env.MESH_GITEA_CONFIG_FILE, "utf8"))["main-mergers"] ?? [];
|
||||
} catch {
|
||||
given = [];
|
||||
}
|
||||
for (const name of Array.isArray(given) ? given : []) {
|
||||
if (typeof name === "string" && LOGIN.test(name) && !name.includes("..") && !out.includes(name)) out.push(name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** What main's rule always says: no push, only the tools' account and main-mergers merge (novox/hq ADR 0398 §2,
|
||||
* issue 948, ADR 0534 §2), and no status required (ADR 0534 §1). */
|
||||
export function mainRuleBody(mergers: string[] = mainMergers()): Record<string, unknown> {
|
||||
return { enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: mergers, ...noStatusBody() };
|
||||
}
|
||||
|
||||
/** The forge's body for a wanted protection. */
|
||||
export function protectionBody(want: ProtectionWanted, creating: boolean, branch = ""): Record<string, unknown> {
|
||||
const checks = [...new Set(want.statusChecks.map((c) => c.trim()).filter(Boolean))];
|
||||
const body: Record<string, unknown> = { enable_status_check: checks.length > 0, status_check_contexts: checks };
|
||||
const body: Record<string, unknown> = noStatusBody();
|
||||
if (want.push !== undefined) body.enable_push = want.push;
|
||||
else if (creating) body.enable_push = false;
|
||||
if (want.blockAdminOverride !== undefined) body.block_admin_merge_override = want.blockAdminOverride;
|
||||
|
||||
@@ -19,13 +19,13 @@ export const CHECK_CONTEXT = "mesh/merge-gate";
|
||||
/** The status context of the repository's own merge-check.sh, the check's second layer (novox/hq ADR 0237). */
|
||||
export const REPO_CHECK_CONTEXT = "mesh/repo-check";
|
||||
|
||||
/** The one status main requires (novox/hq ADR 0447 §1, issue 1301): the checker's verdict, written by its forge bridge
|
||||
* through gitea_check_status. Its line names the head and the trunk commit it judged: "pass <head12> on <trunk12> …". */
|
||||
/** The one status main requires (novox/hq ADR 0447 §1, issue 1301): the checker's verdict, written by this module from the
|
||||
* checker's owed state (check.ts, ADR 0521). Its line names the head and the trunk commit it judged: "pass <head12> on <trunk12> …". */
|
||||
export const CHECK_STATUS = "mesh/check";
|
||||
|
||||
/** The statuses a branch's rule requires, from the rule the forge applies to it: none when it is unprotected or checks
|
||||
* no status; undefined when the rule could not be read. */
|
||||
export function requiredOf(rule: { enable_status_check?: boolean; status_check_contexts?: string[] } | null | undefined): string[] | undefined {
|
||||
export function requiredOf(rule: { enable_status_check?: boolean; status_check_contexts?: string[] | null } | null | undefined): string[] | undefined {
|
||||
if (rule === undefined) return undefined;
|
||||
if (!rule || !rule.enable_status_check) return [];
|
||||
return (rule.status_check_contexts ?? []).filter(Boolean);
|
||||
|
||||
@@ -36,3 +36,76 @@ test("importing the bundle resolves while the broker keeps refusing the binds",
|
||||
clearTimeout(timer);
|
||||
assert.match(out, /imported/, `the import did not resolve within 3 s while a bind was refused; stderr: ${err.slice(0, 600)}`);
|
||||
});
|
||||
|
||||
// **The bundle writes mesh/check from the checker's owed state, and serves no tool that writes it** (ADR 0521; R4e round 2
|
||||
// finding R2-1; finding K1). The bundle is imported whole, as the runtime imports it, while its read of the checker's owed
|
||||
// is refused (the grant not yet composed): the watch is asked again, and once granted it is handed the bucket as the bus
|
||||
// keeps it, the latest revision of each key, then an older revision delivered late. The forge stand-in sees the latest
|
||||
// written, and nothing after it.
|
||||
test("a bundle whose read of the checker's owed is granted late writes the latest mesh/check, and serves no gitea_check_status", async () => {
|
||||
const index = pathToFileURL(join(here, "..", "dist", "index.js")).href;
|
||||
const sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
const script = `
|
||||
const http = await import("node:http");
|
||||
const statuses = [];
|
||||
const forge = http.createServer((req, res) => {
|
||||
let b = ""; req.on("data", (d) => (b += d)); req.on("end", () => {
|
||||
if (req.method === "POST" && /\\/statuses\\//.test(req.url)) statuses.push(req.url + " " + b);
|
||||
res.setHeader("content-type", "application/json"); res.end(req.method === "GET" ? "[]" : "{}");
|
||||
});
|
||||
});
|
||||
await new Promise((r) => forge.listen(0, "127.0.0.1", r));
|
||||
process.env.MESH_GITEA_URL = "http://127.0.0.1:" + forge.address().port;
|
||||
const bound = [];
|
||||
const { useBroker } = await import("@novox/mesh-sdk/messaging");
|
||||
useBroker(() => ({
|
||||
subscribe: async (pattern) => { bound.push(pattern); return () => {}; },
|
||||
publish: async () => {}, request: async () => { throw new Error("no responders"); },
|
||||
handle: async () => () => {}, close: async () => {},
|
||||
}));
|
||||
const key = "novox/mesh-sdk.12.${sha}.status=mesh/check";
|
||||
const owed = (state, text) => ({ v: 1, act: "status", context: "mesh/check", state, text, target: "https://view.example/c" });
|
||||
const watched = [];
|
||||
let granted = 0;
|
||||
const { useStateChannel } = await import("@novox/mesh-sdk/state");
|
||||
useStateChannel({
|
||||
ask: async () => null,
|
||||
watch: async (params, deliver) => {
|
||||
watched.push(params.state);
|
||||
if (watched.length === 1) throw new Error('gitea keeps and reads no state called "mesh-delivery-checker.owed"');
|
||||
granted++;
|
||||
// Two revisions were written while the read was refused; the bucket hands the latest, then a late older one.
|
||||
await deliver({ state: params.state, key, op: "put", value: owed("success", "pass at 4"), revision: 4, current: true });
|
||||
await deliver({ state: params.state, key, op: "put", value: owed("pending", "pending at 3"), revision: 3, current: false });
|
||||
},
|
||||
});
|
||||
await import(${JSON.stringify(index)});
|
||||
const { collectTools } = await import("@novox/mesh-sdk/tools");
|
||||
const tools = collectTools().flatMap((r) => r.tools.map((t) => t.name));
|
||||
for (let i = 0; i < 300 && !(granted && statuses.length); i++) await new Promise((r) => setTimeout(r, 20));
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
console.log(JSON.stringify({ tools, bound, watched, statuses }));
|
||||
process.exit(0);`;
|
||||
const env = { ...process.env, MESH_GITEA_TOKEN: "x" };
|
||||
for (const k of ["MESH_GITEA_STATE_DIR", "MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD", "MESH_SERVED_MODULE", "MESH_GITEA_URL"]) delete env[k];
|
||||
const child = spawn(process.execPath, ["--input-type=module", "-e", script], { cwd: join(here, ".."), env, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let out = "";
|
||||
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"), 15000);
|
||||
await ended;
|
||||
clearTimeout(timer);
|
||||
const line = out.split("\n").find((l) => l.startsWith("{"));
|
||||
assert.ok(line, `the bundle said nothing; stdout: ${out.slice(0, 400)} stderr: ${err.slice(0, 600)}`);
|
||||
const got = JSON.parse(line!) as { tools: string[]; bound: string[]; watched: string[]; statuses: string[] };
|
||||
assert.ok(got.tools.includes("gitea_commit_status"), `the bundle's tools: ${got.tools}`);
|
||||
assert.ok(!got.tools.includes("gitea_check_status"), "no tool writes mesh/check");
|
||||
assert.ok(!got.bound.some((p) => p.includes("check.status")), `bound: ${got.bound}`);
|
||||
assert.deepEqual(got.watched, ["mesh-delivery-checker.owed", "mesh-delivery-checker.owed"], "refused, then asked again");
|
||||
assert.equal(got.statuses.length, 1, `statuses written: ${got.statuses}`);
|
||||
assert.match(got.statuses[0], new RegExp(`/repos/novox/mesh-sdk/statuses/${sha} .*"context":"mesh/check"`));
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// **mesh/check is written from the checker's owed state, read by a watch** (ADR 0521; R4e round 2 finding R2-1; finding K1
|
||||
// before it). No tool writes mesh/check, since a tool is told no caller and node-tools invokes every tool. Nor does an event:
|
||||
// nothing in the delivery plan orders this module before the forge bridge, and a durable consumer whose filters change keeps
|
||||
// its deliver policy, so an event said before this module consumed it was lost. A watch of the checker's state hands over
|
||||
// the latest value of every key when it starts, whenever that is.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
const key = (s = sha, repo = "novox/mesh-sdk", n = 12) => `${repo}.${n}.${s}.status=mesh/check`;
|
||||
const owed = (over: Record<string, unknown> = {}) => ({ v: 1, act: "status", context: "mesh/check", state: "success",
|
||||
text: "pass 0123456789ab on aaaaaaaaaaaa", target: "https://view.example/checks/g.1", ...over });
|
||||
const put = (k: string, value: unknown, revision: number, current = false) =>
|
||||
({ state: "mesh-delivery-checker.owed", key: k, op: "put" as const, value, revision, current });
|
||||
|
||||
function forge() {
|
||||
const sent: string[] = [];
|
||||
let failing = 0;
|
||||
return {
|
||||
sent,
|
||||
fail: (n: number) => { failing = n; },
|
||||
setCommitStatus: async (o: string, r: string, s: string, st: any) => {
|
||||
if (failing > 0) { failing--; throw new Error("Gitea API /repos/x/statuses: 502 bad gateway"); }
|
||||
sent.push(`${o}/${r}@${s.slice(0, 8)} ${st.context} ${st.state} ${st.description}${st.target_url ? " " + st.target_url : ""}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
const noSleep = async () => {};
|
||||
|
||||
test("the checker's owed is what this module reads, and it consumes no check.status", async () => {
|
||||
const { CHECKER_OWED } = await import("../check.ts");
|
||||
assert.equal(CHECKER_OWED, "mesh-delivery-checker.owed");
|
||||
const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8"));
|
||||
assert.ok(manifest.reads.includes(CHECKER_OWED), "gitea's manifest reads the checker's owed");
|
||||
assert.ok(!manifest.consumes.some((c: string) => c.includes("check.status")), "no check.status is consumed");
|
||||
});
|
||||
|
||||
test("an owed mesh/check writes the status on its head: its state, its line cut and its link", async () => {
|
||||
const { CheckStatuses } = await import("../check.ts");
|
||||
const f = forge();
|
||||
const checks = new CheckStatuses(f, () => {}, noSleep);
|
||||
assert.equal(await checks.apply(put(key(), owed(), 7, true)), "written");
|
||||
const other = sha.replace(/0/g, "f");
|
||||
assert.equal(await checks.apply(put(key(other, "novox/my=2erepo"), owed({ state: "pending", text: "y".repeat(300), target: "javascript:x" }), 8)), "written");
|
||||
assert.equal(f.sent[0], "novox/mesh-sdk@01234567 mesh/check success pass 0123456789ab on aaaaaaaaaaaa https://view.example/checks/g.1");
|
||||
assert.ok(f.sent[1].startsWith("novox/my.repo@f1234567 mesh/check pending yyyy"), f.sent[1]);
|
||||
assert.ok(f.sent[1].length < 200, "the line is cut as every status's is");
|
||||
assert.ok(!f.sent[1].includes("javascript"), "a link that is no web page is not written");
|
||||
});
|
||||
|
||||
test("a key or value that is no owed mesh/check, and a delete, write nothing", async () => {
|
||||
const { CheckStatuses } = await import("../check.ts");
|
||||
const f = forge();
|
||||
const checks = new CheckStatuses(f, () => {}, noSleep);
|
||||
const cases = [
|
||||
put(key(sha.slice(0, 12)), owed(), 1), put(`novox/mesh-sdk.12.${sha}.status=mesh/merge-gate`, owed(), 1),
|
||||
put(`novox/mesh-sdk.12.${sha}.view`, owed(), 1), put(`nokey`, owed(), 1), put(`../x/y.12.${sha}.status=mesh/check`, owed(), 1),
|
||||
put(key(), owed({ context: "mesh/delivery" }), 1), put(key(), owed({ act: "view" }), 1), put(key(), owed({ state: "green" }), 1),
|
||||
put(key(), owed({ v: 2 }), 1), put(key(), null, 1), put(key(), "text", 1), put(key(), owed(), -1),
|
||||
{ state: "mesh-delivery-checker.owed", key: key(), op: "delete" as const, revision: 2, current: false },
|
||||
];
|
||||
for (const c of cases) assert.equal(await checks.apply(c as any), "dropped", JSON.stringify(c));
|
||||
assert.deepEqual(f.sent, []);
|
||||
});
|
||||
|
||||
test("a revision older than one written on the key is dropped; the same one is written again", async () => {
|
||||
const { CheckStatuses } = await import("../check.ts");
|
||||
const f = forge();
|
||||
const checks = new CheckStatuses(f, () => {}, noSleep);
|
||||
await checks.apply(put(key(), owed({ state: "success" }), 10));
|
||||
assert.equal(await checks.apply(put(key(), owed({ state: "pending" }), 9)), "dropped", "a stale pending never hides the verdict");
|
||||
assert.equal(await checks.apply(put(key(), owed({ state: "success" }), 10)), "written");
|
||||
assert.equal(await checks.apply(put(key(sha.replace(/1/g, "2")), owed({ state: "pending" }), 3)), "written", "another key is its own");
|
||||
assert.equal(await checks.apply(put(key(), owed({ state: "pending" }), 11)), "written");
|
||||
assert.deepEqual(f.sent.map((s) => s.split(" ").slice(0, 3).join(" ")), ["novox/mesh-sdk@01234567 mesh/check success",
|
||||
"novox/mesh-sdk@01234567 mesh/check success", "novox/mesh-sdk@02234567 mesh/check pending", "novox/mesh-sdk@01234567 mesh/check pending"]);
|
||||
});
|
||||
|
||||
test("a forge that refuses is asked again until it writes, unless a newer revision of the key came meanwhile", async () => {
|
||||
const { CheckStatuses } = await import("../check.ts");
|
||||
const f = forge();
|
||||
const said: string[] = [];
|
||||
const waits: number[] = [];
|
||||
const checks = new CheckStatuses(f, (l) => said.push(l), async (ms) => { waits.push(ms); });
|
||||
f.fail(2);
|
||||
await checks.take(put(key(), owed({ state: "success" }), 5));
|
||||
await checks.settled();
|
||||
assert.deepEqual(f.sent.map((s) => s.split(" ")[2]), ["success"], "written on the third ask");
|
||||
assert.deepEqual(waits, [2_000, 5_000]);
|
||||
assert.match(said[0], /502.*asked again/);
|
||||
// A refusal whose key moved on meanwhile is not asked again: the newer revision is written, the older never after it.
|
||||
const g = forge();
|
||||
let release: () => void = () => {};
|
||||
const gate = new Promise<void>((r) => { release = r; });
|
||||
const later = new CheckStatuses(g, () => {}, () => gate);
|
||||
g.fail(1);
|
||||
await later.take(put(key(), owed({ state: "pending" }), 6));
|
||||
await later.take(put(key(), owed({ state: "success" }), 7));
|
||||
release();
|
||||
await later.settled();
|
||||
assert.deepEqual(g.sent.map((s) => s.split(" ")[2]), ["success"]);
|
||||
});
|
||||
|
||||
test("a watch refused at first is asked again, said once, and once granted writes the latest revision of each key", async () => {
|
||||
const { CheckStatuses, watchOwed } = await import("../check.ts");
|
||||
const f = forge();
|
||||
const said: string[] = [];
|
||||
const checks = new CheckStatuses(f, () => {}, noSleep);
|
||||
// The checker's bucket as the bus keeps it: two revisions of one key written while the read was refused.
|
||||
const kept = new Map<string, { value: unknown; revision: number }>();
|
||||
kept.set(key(), { value: owed({ state: "pending" }), revision: 3 });
|
||||
kept.set(key(), { value: owed({ state: "success" }), revision: 4 });
|
||||
let asked = 0;
|
||||
const watch = async (handler: (c: any) => Promise<void>) => {
|
||||
asked++;
|
||||
if (asked < 3) throw new Error("gitea keeps and reads no state called \"mesh-delivery-checker.owed\"");
|
||||
for (const [k, e] of kept) await handler(put(k, e.value, e.revision, true));
|
||||
};
|
||||
await watchOwed(watch, checks, noSleep, (l) => said.push(l));
|
||||
await checks.settled();
|
||||
assert.equal(asked, 3);
|
||||
assert.deepEqual(f.sent.map((s) => s.split(" ")[2]), ["success"], "only the latest revision is written");
|
||||
assert.equal(said.filter((l) => /could not be bound/.test(l)).length, 1, `said once: ${said.join(" | ")}`);
|
||||
assert.ok(said.some((l) => /bound after 3 attempts/.test(l)), said.join(" | "));
|
||||
});
|
||||
|
||||
test("no tool writes mesh/check: gitea_check_status is gone and gitea_commit_status refuses it", async () => {
|
||||
const delivery = await import("../delivery.ts");
|
||||
assert.equal((delivery as Record<string, unknown>).checkStatusTool, undefined, "delivery.ts serves no gitea_check_status");
|
||||
assert.throws(() => delivery.deliveryStatus("mesh/check", "success", "x"), /mesh-delivery-checker\.owed/);
|
||||
const index = readFileSync(new URL("../index.ts", import.meta.url), "utf8");
|
||||
assert.ok(!/gitea_check_status|checkStatusTool/.test(index), "index.ts serves no gitea_check_status");
|
||||
});
|
||||
@@ -62,37 +62,20 @@ test("one view per pull request, found by its marker; only the mesh's statuses",
|
||||
assert.equal(deliveryStatus("mesh/delivery", "success", "x", "javascript:alert(1)").target_url, undefined);
|
||||
});
|
||||
|
||||
// Issue 1301-S4 (ADR 0447 §1; the lead's choice A): main requires mesh/check, so no hand may set it. The status tool
|
||||
// every caller may use refuses it in every state; it is written only by gitea_check_status, which the forge bridge
|
||||
// alone lists in its manifest's `invokes`, so only its account may call it.
|
||||
test("mesh/check is refused by the status tool and written by the check's own tool alone", async () => {
|
||||
const { commitStatusTool, checkStatusTool, deliveryStatus, CHECK_STATUS } = await import("../delivery.ts");
|
||||
// Issue 1301-S4 (ADR 0447 §1; R4e findings K1 and R2-1): main requires mesh/check, so no hand may set it. The status tool
|
||||
// every caller may use refuses it in every state; no tool writes it at all: it is written from the checker's owed state
|
||||
// (check.ts, test/check-owed.test.ts; ADR 0521).
|
||||
test("mesh/check is refused by the status tool in every state", async () => {
|
||||
const { commitStatusTool, deliveryStatus, CHECK_STATUS } = await import("../delivery.ts");
|
||||
assert.equal(CHECK_STATUS, "mesh/check");
|
||||
const sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
const sent: string[] = [];
|
||||
const setCommitStatus = async (o: string, r: string, s: string, st: any) =>
|
||||
void sent.push(`${o}/${r}@${s.slice(0, 8)} ${st.context} ${st.state} ${st.description}${st.target_url ? " " + st.target_url : ""}`);
|
||||
const setCommitStatus = async (o: string, r: string, s: string, st: any) => void sent.push(`${o}/${r}@${s} ${st.context}`);
|
||||
const gates = { rechecked: async () => { throw new Error("not the gate writer's"); } };
|
||||
for (const state of ["pending", "success", "failure", "error", "warning"]) {
|
||||
assert.throws(() => deliveryStatus("mesh/check", state, "x"), /mesh\/check is the checker's status/);
|
||||
await assert.rejects(commitStatusTool({ setCommitStatus, gates } as any).run({ owner: "novox", repo: "mesh-sdk", sha,
|
||||
context: "mesh/check", state, description: "by hand" }), /gitea_check_status/);
|
||||
context: "mesh/check", state, description: "by hand" }), /mesh-delivery-checker\.owed/);
|
||||
}
|
||||
assert.deepEqual(sent, [], "nothing was sent for a hand-set mesh/check");
|
||||
|
||||
const tool = checkStatusTool({ setCommitStatus });
|
||||
assert.equal(tool.name, "gitea_check_status");
|
||||
for (const k of ["owner", "repo", "sha", "state", "description", "target_url", "context"]) assert.ok(k in tool.input, k);
|
||||
// The bridge's arguments as it sends them for gitea_commit_status: the context may be given, and is mesh/check.
|
||||
await tool.run({ owner: "novox", repo: "mesh-sdk", sha, context: "mesh/check", state: "success", description: "pass",
|
||||
target_url: "https://forge.invalid/novox/mesh-sdk/pulls/1" });
|
||||
await tool.run({ owner: "novox", repo: "mesh-sdk", sha, state: "pending", description: "y".repeat(300) });
|
||||
assert.equal(sent[0], "novox/mesh-sdk@01234567 mesh/check success pass https://forge.invalid/novox/mesh-sdk/pulls/1");
|
||||
assert.ok(sent[1].startsWith("novox/mesh-sdk@01234567 mesh/check pending yyyy"), sent[1]);
|
||||
assert.ok(sent[1].length < 200, "the description is cut as every status's is");
|
||||
await assert.rejects(tool.run({ owner: "novox", repo: "mesh-sdk", sha, context: "mesh/delivery", state: "success" }), /mesh\/check alone/);
|
||||
await assert.rejects(tool.run({ owner: "novox", repo: "mesh-sdk", sha: sha.slice(0, 12), state: "success" }), /whole sha/);
|
||||
await assert.rejects(tool.run({ owner: "novox", repo: "mesh-sdk", sha, state: "green" }), /not a status/);
|
||||
await assert.rejects(tool.run({ owner: "../x", repo: "mesh-sdk", sha, state: "success" }));
|
||||
assert.equal(sent.length, 2, "nothing more was sent");
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ import { GiteaClient } from "../dist/client.js";
|
||||
import { ConfiguredToken } from "../dist/token.js";
|
||||
import { getGiteaTools } from "../dist/tools/index.js";
|
||||
import { mergeTool } from "../dist/merge.js";
|
||||
import { checkStatusTool, commitStatusTool } from "../dist/delivery.js";
|
||||
import { commitStatusTool } from "../dist/delivery.js";
|
||||
import { GateWriter } from "../dist/gate.js";
|
||||
import { judgeApiCall, judgeProtectionChange, ruleCovers, routedPath } from "../dist/guard.js";
|
||||
|
||||
@@ -109,7 +109,7 @@ const server = createServer(async (req, res) => {
|
||||
const rule = (state.rules as any[]).find((r) => ruleCovers(String(r.rule_name ?? r.branch_name), branch));
|
||||
return json(res, 200, { name: branch, commit: { id: branch === "main" ? state.trunk : "c0ffee" }, protected: !!rule,
|
||||
...(rule ? { effective_branch_protection_name: rule.rule_name ?? rule.branch_name, enable_status_check: !!rule.enable_status_check,
|
||||
status_check_contexts: rule.status_check_contexts ?? [], required_approvals: rule.required_approvals ?? 0, user_can_push: false } : {}) });
|
||||
status_check_contexts: rule.status_check_contexts ?? [], required_approvals: rule.required_approvals ?? 0, user_can_push: !!rule.enable_push } : {}) });
|
||||
}
|
||||
if (method === "GET" && path === `${repo}/pulls/7/files`) {
|
||||
return json(res, 200, url.searchParams.get("page") === "1" ? [{ filename: "modules/gitea/x.ts", status: "modified" }] : []);
|
||||
@@ -168,7 +168,7 @@ const gates = { rechecked: (o: string, r: string, sha: string, s: any) => {
|
||||
} };
|
||||
const tools = [...getGiteaTools(client), commitStatusTool({
|
||||
setCommitStatus: (o, r, sha, s) => client.setCommitStatus(o, r, sha, s), gates: writer,
|
||||
}), checkStatusTool({ setCommitStatus: (o, r, sha, s) => client.setCommitStatus(o, r, sha, s) }), mergeTool(client, { gates, stateDir, emit: async (type, body) => { state.emitted.push({ type, body }); },
|
||||
}), mergeTool(client, { gates, stateDir, emit: async (type, body) => { state.emitted.push({ type, body }); },
|
||||
now: () => new Date(2026, 9, 12, 15, 1) })];
|
||||
const tool = (name: string) => tools.find((t) => t.name === name)!;
|
||||
const writes = () => state.sent.map((s) => `${s.method} ${s.path}`);
|
||||
@@ -237,18 +237,24 @@ test("gitea_api still writes what an author writes", async () => {
|
||||
assert.deepEqual(writes(), ["POST /repos/novox/mesh-catalog/issues/3/comments", "POST /repos/novox/mesh-catalog/pulls/7/reviews"]);
|
||||
});
|
||||
|
||||
test("gitea_branch_protection_set only strengthens", async () => {
|
||||
// **The forge requires no status to merge** (novox/hq ADR 0534 §5): the tool writes no required status and takes no
|
||||
// status argument; taking a required status away is no weakening it refuses, and a push or an administrator's merge
|
||||
// past the rule still is.
|
||||
test("gitea_branch_protection_set writes no required status, and refuses a push or an administrator's override", async () => {
|
||||
state.admin = true; // a repository administrator's token: the tools' account is refused the rules by the forge
|
||||
const set = (args: Record<string, unknown>) =>
|
||||
tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-catalog", branch: "main", ...args });
|
||||
await assert.rejects(set({ status_checks: "" }), /drops the required status mesh\/merge-gate, mesh\/repo-check/);
|
||||
await assert.rejects(set({ status_checks: "mesh/merge-gate" }), /drops the required status mesh\/repo-check/);
|
||||
await assert.rejects(set({ status_checks: "mesh/merge-gate,mesh/repo-check", push: true }), /push to the branch directly/);
|
||||
await assert.rejects(set({ status_checks: "mesh/merge-gate,mesh/repo-check", block_admin_override: false }),
|
||||
/merge past a status/);
|
||||
assert.equal((tool("gitea_branch_protection_set").input as Record<string, unknown>).status_checks, undefined,
|
||||
"the tool takes no status argument");
|
||||
await assert.rejects(set({ push: true }), /push to the branch directly/);
|
||||
await assert.rejects(set({ block_admin_override: false }), /merge past/);
|
||||
assert.deepEqual(writes(), []);
|
||||
await set({ status_checks: "mesh/merge-gate,mesh/repo-check,mesh/delivery" });
|
||||
await set({ status_checks: "mesh/check" }); // an argument it no longer takes is not read
|
||||
assert.deepEqual(writes(), ["PATCH /repos/novox/mesh-catalog/branch_protections/main"]);
|
||||
const sent = state.sent[0].body;
|
||||
assert.equal(sent.enable_status_check, false);
|
||||
assert.deepEqual(sent.status_check_contexts, []);
|
||||
assert.equal(sent.enable_push, false);
|
||||
});
|
||||
|
||||
test("gitea_merge_pull_request merges only a head whose required statuses all succeeded, and only that head", async () => {
|
||||
@@ -474,26 +480,62 @@ test("gitea_api sends the path it judged, and refuses moving a pull request's ba
|
||||
assert.equal(writes().length, 2);
|
||||
});
|
||||
|
||||
test("gitea_merge_pull_request merges only into a branch whose protection requires the checks", async () => {
|
||||
test("gitea_merge_pull_request merges only into a branch whose protection refuses its push", async () => {
|
||||
const approved_heads = [`novox/mesh-catalog@${REVIEWED}`];
|
||||
state.statuses = [{ context: "mesh/check", state: "success", description: `pass ${REVIEWED.slice(0, 12)} on ${TRUNK.slice(0, 12)}` }];
|
||||
state.rules = [];
|
||||
await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED }),
|
||||
/no protection rule requiring the checks guards/);
|
||||
state.rules = [{ rule_name: "main", enable_push: false, enable_status_check: false, status_check_contexts: [] }];
|
||||
await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED }),
|
||||
/no protection rule requiring the checks guards/);
|
||||
await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED,
|
||||
approved_heads }), /no protection rule refusing/);
|
||||
state.rules = [{ rule_name: "main", enable_push: true, enable_status_check: false, status_check_contexts: [] }];
|
||||
await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED,
|
||||
approved_heads }), /no protection rule refusing/);
|
||||
assert.deepEqual(writes(), []);
|
||||
});
|
||||
|
||||
// **The forge requires no status to merge; the merge tool still judges mesh/check, and asks the operator's Approve**
|
||||
// (novox/hq ADR 0534 §4): on a main whose rule takes no push and requires no status, a head merges only with
|
||||
// approved_heads naming it and a mesh/check that passed against the trunk's head at the merge.
|
||||
test("gitea_merge_pull_request judges mesh/check on a main requiring no status, and merges only an approved head", async () => {
|
||||
const { keepSiblings, siblingsFile } = await import("../dist/siblings.js");
|
||||
keepSiblings(siblingsFile(stateDir), {});
|
||||
const approved_heads = [`novox/mesh-catalog@${REVIEWED}`];
|
||||
const merge = (args: Record<string, unknown> = { approved_heads }) =>
|
||||
tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED, ...args });
|
||||
state.rules = [{ rule_name: "main", enable_push: false, enable_status_check: false, status_check_contexts: [],
|
||||
block_admin_merge_override: true, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"] }];
|
||||
const line = (trunk: string, s = "success", words = "pass") =>
|
||||
[{ context: "mesh/check", state: s, description: `${words} ${REVIEWED.slice(0, 12)} on ${trunk.slice(0, 12)} | unit 12 run` }];
|
||||
const before = state.emitted.length;
|
||||
|
||||
state.statuses = line(TRUNK);
|
||||
await assert.rejects(merge({}), /Approve/);
|
||||
state.statuses = [];
|
||||
await assert.rejects(merge(), /mesh\/check is not set yet/);
|
||||
state.statuses = line(TRUNK, "pending", "checking");
|
||||
await assert.rejects(merge(), /mesh\/check is pending/);
|
||||
state.statuses = line(TRUNK, "failure", "fail");
|
||||
await assert.rejects(merge(), /mesh\/check is failure/);
|
||||
state.statuses = line(MOVED);
|
||||
await assert.rejects(merge(), new RegExp(`${MOVED.slice(0, 12)}.*${TRUNK.slice(0, 12)}`));
|
||||
state.statuses = judgedAt(TRUNK); // the old two, current: not mesh/check
|
||||
await assert.rejects(merge(), /mesh\/check/);
|
||||
assert.deepEqual(writes(), [], "nothing is written: mesh/check is the checker's");
|
||||
assert.equal(state.emitted.length, before, "no check is asked");
|
||||
assert.equal(state.merged, false);
|
||||
|
||||
state.statuses = line(TRUNK);
|
||||
assert.equal(((await merge()) as Record<string, unknown>).merged, true);
|
||||
});
|
||||
|
||||
test("a rule named for a branch is not made weaker than the glob rule that guards it now", async () => {
|
||||
state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365)
|
||||
state.rules.push({ rule_name: "release/*", enable_push: false, enable_status_check: true,
|
||||
status_check_contexts: ["mesh/merge-gate"], block_admin_merge_override: true });
|
||||
const set = (args: Record<string, unknown>) =>
|
||||
tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-catalog", branch: "release/1", ...args });
|
||||
await assert.rejects(set({ status_checks: "" }), /drops the required status mesh\/merge-gate/);
|
||||
await assert.rejects(set({ status_checks: "mesh/merge-gate" }), /merge past a status/);
|
||||
await assert.rejects(set({}), /merge past/);
|
||||
assert.deepEqual(writes(), []);
|
||||
await set({ status_checks: "mesh/merge-gate", block_admin_override: true });
|
||||
await set({ block_admin_override: true }); // the glob's required status is no longer asked (ADR 0534 §5)
|
||||
assert.deepEqual(writes(), ["POST /repos/novox/mesh-catalog/branch_protections"]);
|
||||
});
|
||||
|
||||
@@ -511,26 +553,25 @@ test("the judges, without a forge", () => {
|
||||
assert.ok("refused" in judgeApiCall("POST", "/repos/a/b/branch_protections", {}));
|
||||
assert.ok("contents" in judgeApiCall("POST", "/repos/a/b/contents", { new_branch: "x" }));
|
||||
assert.ok(ruleCovers("release/**", "release/a/b") && ruleCovers("feat/*", "feat/a") && !ruleCovers("feat/*", "feat/a/b"));
|
||||
assert.equal(judgeProtectionChange(null, { statusChecks: [] }), null);
|
||||
assert.equal(judgeProtectionChange(protectedMain(), { statusChecks: ["mesh/repo-check", "mesh/merge-gate"] }), null);
|
||||
assert.equal(judgeProtectionChange(null, {}), null);
|
||||
assert.equal(judgeProtectionChange(protectedMain(), {}), null, "taking its required statuses off is no weakening (ADR 0534 §5)");
|
||||
});
|
||||
|
||||
test("main's protection is never weakened in its push or its merge whitelist (issue 948, ADR 0398 §2)", () => {
|
||||
const strong = () => ({ ...protectedMain(), enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"] });
|
||||
const checks = ["mesh/merge-gate", "mesh/repo-check"];
|
||||
assert.equal(judgeProtectionChange(strong(), { statusChecks: checks }, [], "main"), null);
|
||||
assert.equal(judgeProtectionChange(strong(), { statusChecks: checks, mergeWhitelist: ["mesh-tools"] }, [], "main"), null);
|
||||
assert.match(judgeProtectionChange(strong(), { statusChecks: checks, mergeWhitelist: [] }, [], "main") ?? "", /merge whitelist/);
|
||||
assert.match(judgeProtectionChange(strong(), { statusChecks: checks, mergeWhitelist: null }, [], "main") ?? "", /merge whitelist/);
|
||||
assert.match(judgeProtectionChange(strong(), { statusChecks: checks, mergeWhitelist: ["mesh-tools", "agent-ace"] }, [], "main") ?? "", /widens/);
|
||||
assert.match(judgeProtectionChange(strong(), { statusChecks: checks, mergeWhitelist: ["agent-ace"] }, [], "main") ?? "", /widens/);
|
||||
assert.match(judgeProtectionChange(strong(), { statusChecks: checks, push: true }, [], "main") ?? "", /push/);
|
||||
assert.equal(judgeProtectionChange(strong(), {}, [], "main"), null);
|
||||
assert.equal(judgeProtectionChange(strong(), { mergeWhitelist: ["mesh-tools"] }, [], "main"), null);
|
||||
assert.match(judgeProtectionChange(strong(), { mergeWhitelist: [] }, [], "main") ?? "", /merge whitelist/);
|
||||
assert.match(judgeProtectionChange(strong(), { mergeWhitelist: null }, [], "main") ?? "", /merge whitelist/);
|
||||
assert.match(judgeProtectionChange(strong(), { mergeWhitelist: ["mesh-tools", "agent-ace"] }, [], "main") ?? "", /widens/);
|
||||
assert.match(judgeProtectionChange(strong(), { mergeWhitelist: ["agent-ace"] }, [], "main") ?? "", /widens/);
|
||||
assert.match(judgeProtectionChange(strong(), { push: true }, [], "main") ?? "", /push/);
|
||||
// Even a rule that lets a person push already, or has no rule yet, is not given a push through a tool.
|
||||
assert.match(judgeProtectionChange({ ...strong(), enable_push: true }, { statusChecks: checks, push: true }, [], "main") ?? "", /push/);
|
||||
assert.match(judgeProtectionChange(null, { statusChecks: checks, push: true }, [], "main") ?? "", /push/);
|
||||
assert.match(judgeProtectionChange(null, { statusChecks: checks, mergeWhitelist: ["agent-ace"] }, [], "main") ?? "", /widens/);
|
||||
assert.match(judgeProtectionChange({ ...strong(), enable_push: true }, { push: true }, [], "main") ?? "", /push/);
|
||||
assert.match(judgeProtectionChange(null, { push: true }, [], "main") ?? "", /push/);
|
||||
assert.match(judgeProtectionChange(null, { mergeWhitelist: ["agent-ace"] }, [], "main") ?? "", /widens/);
|
||||
// Another branch is judged as before.
|
||||
assert.equal(judgeProtectionChange(null, { statusChecks: checks, push: true }, [], "feat/x"), null);
|
||||
assert.equal(judgeProtectionChange(null, { push: true }, [], "feat/x"), null);
|
||||
});
|
||||
|
||||
test("gitea_api never acts as another user: sudo, in any case, and any query key it does not send are refused", async () => {
|
||||
@@ -568,9 +609,6 @@ test("no tool's caller text becomes another path or another user: `..` and `?sud
|
||||
["gitea_get_file", { ...repo, path: "README.md?sudo=jschoubben" }],
|
||||
["gitea_commit_status", { ...repo, sha: "abc1234?sudo=jschoubben", context: "mesh/delivery", state: "success" }],
|
||||
["gitea_commit_status", { ...repo, sha: "../../../admin/users", context: "mesh/delivery", state: "success" }],
|
||||
["gitea_check_status", { ...repo, sha: "abc1234?sudo=jschoubben", state: "success" }],
|
||||
["gitea_check_status", { ...repo, sha: "../../../admin/users", state: "success" }],
|
||||
["gitea_check_status", { owner: "novox", repo: "..", sha: "0123456789abcdef0123456789abcdef01234567", state: "success" }],
|
||||
["gitea_commit_status", { ...repo, sha: "0123456789abcdef0123456789abcdef01234567", context: "mesh/check", state: "success" }],
|
||||
["gitea_commit_statuses", { ...repo, sha: ".." }],
|
||||
["gitea_commit_statuses", { ...repo, sha: "main?sudo=jschoubben" }],
|
||||
|
||||
@@ -52,7 +52,7 @@ test("gitea_branch_protection_get answers the rule for a branch, null when it ha
|
||||
|
||||
test("gitea_branch_protection_set still gets the forge's refusal, as the tools' account", async () => {
|
||||
await assert.rejects(
|
||||
tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-controller", branch: "main", status_checks: "mesh/merge-gate" }),
|
||||
tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-controller", branch: "main" }),
|
||||
/403/,
|
||||
);
|
||||
assert.ok(!seen.some((s) => /^(PATCH|POST|PUT|DELETE) .*branch_protections.* as admin$/.test(s)), seen.join("\n"));
|
||||
|
||||
@@ -4,32 +4,33 @@ import { test } from "node:test";
|
||||
// A branch's protection (novox/hq ADR 0237): what the operator's agent sets so a pull request waits for the
|
||||
// mesh's merge check before it merges.
|
||||
|
||||
test("the statuses asked for replace the rule's, and an empty list requires none", async () => {
|
||||
test("a rule's body requires no status, whatever the rule required (ADR 0534 §5)", async () => {
|
||||
const { protectionBody } = await import("../dist/protection.js");
|
||||
assert.deepEqual(protectionBody({ statusChecks: ["mesh/merge-gate", " mesh/repo-check", "", "mesh/merge-gate"] }, false),
|
||||
{ enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"] },
|
||||
"an edited rule keeps its pushes as they are, and each status once");
|
||||
assert.deepEqual(protectionBody({ statusChecks: [] }, false), { enable_status_check: false, status_check_contexts: [] });
|
||||
assert.deepEqual(protectionBody({}, false), { enable_status_check: false, status_check_contexts: [] },
|
||||
"an edited rule keeps its pushes as they are, and requires no status");
|
||||
});
|
||||
|
||||
test("a new rule refuses direct pushes unless asked; an administrator's override only when said", async () => {
|
||||
const { protectionBody } = await import("../dist/protection.js");
|
||||
assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"] }, true).enable_push, false);
|
||||
assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"], push: true }, true).enable_push, true);
|
||||
assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"] }, true).block_admin_merge_override, undefined);
|
||||
assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"], blockAdminOverride: true }, false).block_admin_merge_override, true);
|
||||
assert.equal(protectionBody({}, true).enable_push, false);
|
||||
assert.equal(protectionBody({ push: true }, true).enable_push, true);
|
||||
assert.equal(protectionBody({}, true).block_admin_merge_override, undefined);
|
||||
assert.equal(protectionBody({ blockAdminOverride: true }, false).block_admin_merge_override, true);
|
||||
});
|
||||
|
||||
// Issue 948 (novox/hq ADR 0398 §2): main takes no push, and only the tools' account merges.
|
||||
// Issue 948 (novox/hq ADR 0398 §2): main takes no push, and only the tools' account merges; it requires no status
|
||||
// (ADR 0534 §1).
|
||||
test("main's body always refuses pushes and lets only the tools' account merge, whatever is asked", async () => {
|
||||
const { protectionBody } = await import("../dist/protection.js");
|
||||
const { TOOLS_ACCOUNT } = await import("../dist/tools-account.js");
|
||||
for (const creating of [true, false]) {
|
||||
for (const want of [{ statusChecks: ["mesh/merge-gate"] }, { statusChecks: [], push: true }, { statusChecks: [], push: false }]) {
|
||||
for (const want of [{}, { push: true }, { push: false }]) {
|
||||
const body = protectionBody(want, creating, "main");
|
||||
assert.equal(body.enable_push, false, "main takes no push, an edit and a push=true included");
|
||||
assert.equal(body.enable_merge_whitelist, true);
|
||||
assert.deepEqual(body.merge_whitelist_usernames, [TOOLS_ACCOUNT]);
|
||||
assert.equal(body.enable_status_check, false);
|
||||
assert.deepEqual(body.status_check_contexts, []);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(TOOLS_ACCOUNT, "mesh-tools");
|
||||
@@ -37,13 +38,28 @@ test("main's body always refuses pushes and lets only the tools' account merge,
|
||||
|
||||
test("another branch's body carries no merge whitelist", async () => {
|
||||
const { protectionBody } = await import("../dist/protection.js");
|
||||
const body = protectionBody({ statusChecks: ["mesh/merge-gate"] }, false, "release/1");
|
||||
const body = protectionBody({}, false, "release/1");
|
||||
assert.equal(body.enable_merge_whitelist, undefined);
|
||||
assert.equal(body.merge_whitelist_usernames, undefined);
|
||||
assert.equal(protectionBody({ statusChecks: [] }, false).enable_merge_whitelist, undefined);
|
||||
});
|
||||
|
||||
test("the rule main is put right in the forge's own words: the body a setup sends carries no status checks of its own", async () => {
|
||||
test("the rule main is put right in the forge's own words: no push, the tools' account merges, no status required", async () => {
|
||||
const { mainRuleBody } = await import("../dist/protection.js");
|
||||
assert.deepEqual(mainRuleBody(), { enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"] });
|
||||
assert.deepEqual(mainRuleBody(), { enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"],
|
||||
enable_status_check: false, status_check_contexts: [] });
|
||||
});
|
||||
|
||||
// The operator's own login stays on main's merge whitelist beside the tools' account (novox/hq ADR 0534 §2): read from
|
||||
// the module's config value main-mergers, in the runtime config file; nothing there leaves the tools' account alone.
|
||||
test("main's merge whitelist is the tools' account, then the configured mergers once each", async () => {
|
||||
const { mainMergers, mainRuleBody } = await import("../dist/protection.js");
|
||||
const { mkdtempSync, writeFileSync } = await import("node:fs");
|
||||
const { join } = await import("node:path");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const file = join(mkdtempSync(join(tmpdir(), "gitea-mergers-")), "config.json");
|
||||
writeFileSync(file, JSON.stringify({ url: "", "main-mergers": ["jschoubben", "mesh-tools", "jschoubben", "../x", "a b"] }));
|
||||
assert.deepEqual(mainMergers({ MESH_GITEA_CONFIG_FILE: file }), ["mesh-tools", "jschoubben"]);
|
||||
assert.deepEqual(mainMergers({}), ["mesh-tools"]);
|
||||
assert.deepEqual(mainMergers({ MESH_GITEA_CONFIG_FILE: join(tmpdir(), "no-such-gitea-config.json") }), ["mesh-tools"]);
|
||||
assert.deepEqual(mainRuleBody(["mesh-tools", "jschoubben"]).merge_whitelist_usernames, ["mesh-tools", "jschoubben"]);
|
||||
});
|
||||
|
||||
@@ -216,3 +216,12 @@ test("a base's required statuses decide which of the controller's statuses are w
|
||||
const comment = commentFor(c, {}, ["mesh/repo-check"]);
|
||||
assert.ok(comment === null || !comment.includes("mesh/merge-gate"), comment ?? "");
|
||||
});
|
||||
|
||||
test("a rule the forge gives with no contexts (null) requires nothing, and an unread rule is unknown", async () => {
|
||||
const { requiredOf } = await import("../pulls.ts");
|
||||
// The forge's BranchProtection says status_check_contexts may be null (protection.ts); requiredOf takes it as it comes.
|
||||
assert.deepEqual(requiredOf({ enable_status_check: true, status_check_contexts: null }), []);
|
||||
assert.deepEqual(requiredOf({ enable_status_check: true, status_check_contexts: ["mesh/check", ""] }), ["mesh/check"]);
|
||||
assert.deepEqual(requiredOf(null), []);
|
||||
assert.equal(requiredOf(undefined), undefined);
|
||||
});
|
||||
|
||||
@@ -235,7 +235,7 @@ test("with the tools' token, the forge writes a status and refuses a protection
|
||||
const client = fresh(env);
|
||||
await client.setCommitStatus("novox", "hq", "abc123def4567890abc123def4567890abc12345", { state: "success", context: "mesh/merge-gate", description: "ok" } as any);
|
||||
await assert.rejects(
|
||||
client.setBranchProtection("novox", "hq", "main", { statusChecks: ["mesh/merge-gate"] }),
|
||||
client.setBranchProtection("novox", "hq", "main", {}),
|
||||
/403/,
|
||||
);
|
||||
});
|
||||
@@ -350,7 +350,7 @@ test("a team another process made between the look and the create is found, not
|
||||
assert.ok(team?.members.has(TOOLS_ACCOUNT));
|
||||
});
|
||||
|
||||
test("setting the tools' account up puts main's rule right in every repository: no push, only the tools' account merges (issue 948)", async () => {
|
||||
test("setting the tools' account up puts main's rule right in every repository: no push, only the tools' account merges, no status (issue 948, ADR 0534 §1)", async () => {
|
||||
const env = await environment();
|
||||
const from = forge.calls.length;
|
||||
await fresh(env).listRepos();
|
||||
@@ -358,7 +358,8 @@ test("setting the tools' account up puts main's rule right in every repository:
|
||||
assert.equal(hq.enable_push, false);
|
||||
assert.equal(hq.enable_merge_whitelist, true);
|
||||
assert.deepEqual(hq.merge_whitelist_usernames, [TOOLS_ACCOUNT]);
|
||||
assert.deepEqual(hq.status_check_contexts, ["mesh/merge-gate"], "a rule found keeps the statuses it requires");
|
||||
assert.deepEqual(hq.status_check_contexts, [], "a rule found has its required statuses taken off (ADR 0534 §1)");
|
||||
assert.equal(hq.enable_status_check, false);
|
||||
const catalog = forge.mainRules.get("mesh-catalog");
|
||||
assert.ok(catalog, "a repository with no rule for main is given one");
|
||||
assert.equal(catalog.rule_name, "main");
|
||||
@@ -368,13 +369,13 @@ test("setting the tools' account up puts main's rule right in every repository:
|
||||
"only the administrator's login touches a rule: the tools' account is no repository's administrator");
|
||||
});
|
||||
|
||||
test("a glob rule covering main keeps its statuses when main is given a rule of its own name (issue 948, review round 1)", async () => {
|
||||
test("a glob rule covering main gives its approvals and override, not its statuses, when main is given a rule of its own name (issue 948, ADR 0534 §1)", async () => {
|
||||
const env = await environment();
|
||||
await fresh(env).listRepos();
|
||||
const rule = forge.mainRules.get("globbed");
|
||||
assert.ok(rule, "main is given its own rule");
|
||||
assert.deepEqual(rule.status_check_contexts, ["mesh/merge-gate", "mesh/repo-check"]);
|
||||
assert.equal(rule.enable_status_check, true);
|
||||
assert.deepEqual(rule.status_check_contexts, []);
|
||||
assert.equal(rule.enable_status_check, false);
|
||||
assert.equal(rule.block_admin_merge_override, true);
|
||||
assert.equal(rule.required_approvals, 1);
|
||||
assert.equal(rule.enable_push, false);
|
||||
|
||||
@@ -429,14 +429,13 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
},
|
||||
{
|
||||
name: "gitea_branch_protection_set",
|
||||
description: "Only ever stronger (novox/hq ADR 0266): a change that drops a required status, lets a person push directly or lets an administrator merge past a status is refused, and on main so is a change that drops the merge whitelist (only mesh-tools merges there) or widens it (novox/hq ADR 0398 §2, issue 948) — that is the operator's, at the forge. Make a branch require commit statuses before a pull request merges into it — the mesh's checker sets `mesh/check` (ADR 0447), and the repositories program requires it on every main. Edits the branch's rule (its required statuses replaced by these, everything else kept unless given) or creates one, which refuses direct pushes unless push=true. Answers the rule before and after.",
|
||||
description: "Protect a branch. Never weaker (novox/hq ADR 0266): a change that lets a person push directly or lets an administrator merge past the rule is refused, and on main so is a change that drops the merge whitelist (only mesh-tools merges there) or widens it (novox/hq ADR 0398 §2, issue 948) — that is the operator's, at the forge. The forge requires no status to merge (ADR 0534): the rule written requires none, and one the rule required is taken off; the checks are judged by mesh-delivery and the merge tool. Edits the branch's rule (everything else kept unless given) or creates one, which refuses direct pushes unless push=true. Answers the rule before and after.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
branch: { type: "string", description: "the branch to protect, e.g. main" },
|
||||
status_checks: { type: "string", description: "comma-separated statuses required to merge, e.g. mesh/check; empty requires none" },
|
||||
push: { type: "boolean", description: "whether a person may push to the branch directly (a new rule refuses it when not given)" },
|
||||
block_admin_override: { type: "boolean", description: "stop an administrator merging past a status that has not succeeded (left as it is when not given)" },
|
||||
block_admin_override: { type: "boolean", description: "stop an administrator merging past what the rule asks (left as it is when not given)" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const owner = String(args.owner), repo = String(args.repo), branch = String(args.branch ?? "").trim();
|
||||
@@ -445,7 +444,6 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
|
||||
const before = await gitea.branchProtection(owner, repo, branch);
|
||||
const want = {
|
||||
statusChecks: String(args.status_checks ?? "").split(","),
|
||||
push: args.push === undefined ? undefined : Boolean(args.push),
|
||||
blockAdminOverride: args.block_admin_override === undefined ? undefined : Boolean(args.block_admin_override),
|
||||
};
|
||||
|
||||
@@ -128,7 +128,7 @@ export interface TrunkForge {
|
||||
branchHead(owner: string, repo: string, branch: string): Promise<string>;
|
||||
/** The rule the forge applies to a branch: which of the controller's statuses are kept there (issue 1301). Not given,
|
||||
* or not read: both, as before. */
|
||||
appliedProtection?(owner: string, repo: string, branch: string): Promise<{ enable_status_check?: boolean; status_check_contexts?: string[] } | null>;
|
||||
appliedProtection?(owner: string, repo: string, branch: string): Promise<{ enable_status_check?: boolean; status_check_contexts?: string[] | null } | null>;
|
||||
}
|
||||
|
||||
/** What it writes with: the merge check's one writer (gate.ts), which decides in the head's own turn. */
|
||||
|
||||
@@ -39,12 +39,8 @@ func TestTheBaseParses(t *testing.T) {
|
||||
t.Skip("logrotate is not installed here; the base configuration is not dry-run")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
content := strings.ReplaceAll(templateText(t, manifest(t).resource(t, "config")), "include "+RulesDir, "include "+filepath.Join(dir, "d"))
|
||||
if err := os.Mkdir(filepath.Join(dir, "d"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf := filepath.Join(dir, "logrotate.conf")
|
||||
if err := os.WriteFile(conf, []byte(content), 0o644); err != nil {
|
||||
if err := os.WriteFile(conf, []byte(dryRunBase(t, dir)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := exec.Command(logrotate, "-d", "-s", filepath.Join(dir, "state"), conf).CombinedOutput()
|
||||
@@ -52,3 +48,40 @@ func TestTheBaseParses(t *testing.T) {
|
||||
t.Fatalf("logrotate -d: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// The dry run proves the base parses, not what the machine's logs are: the unit level runs shut off in
|
||||
// bubblewrap (mesh-sdk buildstep.Isolated), where /var/log is not there, and a base block without
|
||||
// missingok then fails its stat (issue 1891).
|
||||
func TestTheDryRunReadsNothingOfTheMachine(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
content := dryRunBase(t, dir)
|
||||
for _, machine := range []string{"include " + RulesDir, "/var/log/"} {
|
||||
if strings.Contains(content, machine) {
|
||||
t.Fatalf("the dry run's base still names %s of the machine:\n%s", machine, content)
|
||||
}
|
||||
}
|
||||
for _, log := range []string{"wtmp", "btmp"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, "log", log)); err != nil {
|
||||
t.Fatalf("the base's own log %s is not in the dry run's directory: %v", log, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dryRunBase is the base configuration pointed into dir: its include at dir/d, and its own logs at dir/log,
|
||||
// both made there.
|
||||
func dryRunBase(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
content := strings.ReplaceAll(templateText(t, manifest(t).resource(t, "config")), "include "+RulesDir, "include "+filepath.Join(dir, "d"))
|
||||
content = strings.ReplaceAll(content, "/var/log/", filepath.Join(dir, "log")+"/")
|
||||
for _, d := range []string{"d", "log"} {
|
||||
if err := os.Mkdir(filepath.Join(dir, d), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, log := range []string{"wtmp", "btmp"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, "log", log), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ func (m *Module) integrationShutOff(ctx context.Context, workspace string, packa
|
||||
}
|
||||
iso := m.isolation(workspace, false)
|
||||
iso.ReadOnly = append(iso.ReadOnly, exe) // the program alone, not the directory it lies in
|
||||
iso.ReadOnly = append(iso.ReadOnly, m.nodeToolchains()...)
|
||||
r := buildstep.Isolated(m.Run, iso)(ctx, nil, exe, levelArg, string(spec))
|
||||
var answer levelAnswer
|
||||
line := lastLine(r.Stdout)
|
||||
@@ -80,6 +81,25 @@ func (m *Module) integrationShutOff(ctx context.Context, workspace string, packa
|
||||
return answer.Result, nil
|
||||
}
|
||||
|
||||
// NodeToolchains is the Node build module's toolchains directory on this node: its cache directory (module.json
|
||||
// resource `cache`, mode 0711) joined with toolchain, where it installs typescript, esbuild, @novox/mesh-sdk and nats
|
||||
// from its own lock (ADR 0533 §1, §2; issue 1891).
|
||||
const NodeToolchains = "/var/lib/mesh-build-node-cache/toolchain"
|
||||
|
||||
// nodeToolchains is what the integration level is given of the Node build module's toolchains: their directory,
|
||||
// read-only, when it is there and this account may enter it, and nothing on a node where the Node build module has
|
||||
// installed none (ADR 0533 §1). The unit level is never given it.
|
||||
func (m *Module) nodeToolchains() []string {
|
||||
dir := m.NodeToolchains
|
||||
if dir == "" {
|
||||
dir = NodeToolchains
|
||||
}
|
||||
if _, err := os.ReadDir(dir); err != nil {
|
||||
return nil
|
||||
}
|
||||
return []string{dir}
|
||||
}
|
||||
|
||||
func lastLine(s string) string {
|
||||
lines := strings.Split(strings.TrimSpace(s), "\n")
|
||||
return strings.TrimSpace(lines[len(lines)-1])
|
||||
|
||||
@@ -62,6 +62,9 @@ type Module struct {
|
||||
Push func(ctx context.Context, registry, repository, digest string, body []byte) error
|
||||
// Executable is this program's path, which the integration level starts again shut off; os.Executable when empty.
|
||||
Executable string
|
||||
// NodeToolchains is where the Node build module keeps its installed toolchains, bound read-only into the
|
||||
// integration level (ADR 0533 §1); NodeToolchains when empty. A test points it elsewhere.
|
||||
NodeToolchains string
|
||||
// StartBus starts the throwaway bus of a run in dir and answers its address and what stops it (the module's own
|
||||
// program, StartBus in bus.go, when nil).
|
||||
StartBus func(ctx context.Context, dir string) (url string, stop func(), err error)
|
||||
|
||||
@@ -13,7 +13,8 @@ import (
|
||||
|
||||
// ADR 0499 §4 (issue 1758): a fetch asked with the module's directory and its named paths leaves the workspace holding
|
||||
// only those, the manifest and the lock files; the rest of the commit is gone before any run step; a request naming
|
||||
// paths without a directory is refused before anything is fetched.
|
||||
// paths without a directory is refused before anything is fetched. go.mod is kept only when named among the paths
|
||||
// (mesh-sdk's narrowKept is the manifest and the lock files, review finding S1), as a Go build that names its paths does.
|
||||
func TestAFetchWithNamedPathsNarrowsTheWorkspace(t *testing.T) {
|
||||
f := newForge(t)
|
||||
_, head := f.repo("novox/narrow", map[string]string{
|
||||
@@ -26,7 +27,7 @@ func TestAFetchWithNamedPathsNarrowsTheWorkspace(t *testing.T) {
|
||||
"other/o.go": "package other\n",
|
||||
})
|
||||
m, states := f.module(t)
|
||||
res, st := fetched(t, m, states, map[string]any{"repository": "novox/narrow", "commit": head, "dir": "lib", "paths": []any{"cmd"}})
|
||||
res, st := fetched(t, m, states, map[string]any{"repository": "novox/narrow", "commit": head, "dir": "lib", "paths": []any{"cmd", "go.mod"}})
|
||||
if st.State != delivery.StepPassed {
|
||||
t.Fatalf("the fetch ended %s: %s", st.State, st.Reason)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
// integrationShutOffWith runs the integration level through the fake bubblewrap and answers what its one shut-off
|
||||
// process was given.
|
||||
func integrationShutOffWith(t *testing.T, toolchains string) shutOff {
|
||||
t.Helper()
|
||||
m, states, ws, _ := workspaceFor(t, "failing")
|
||||
m.Executable = "/opt/bundle/mesh-build-go"
|
||||
m.NodeToolchains = toolchains
|
||||
want := TestResult{Level: LevelIntegration, Passed: true, Tests: 1, Packages: []PackageResult{}, Store: "postgres 18"}
|
||||
var seen []shutOff
|
||||
m.Run = throughBubblewrap(t, func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
||||
switch {
|
||||
case name == "postgres" && len(args) == 1 && args[0] == "--version":
|
||||
return forgekey.Ran{Stdout: "postgres (PostgreSQL) 18.6\n"}
|
||||
case name == m.Executable && len(args) == 2 && args[0] == levelArg:
|
||||
raw, _ := json.Marshal(levelAnswer{Result: &want})
|
||||
return forgekey.Ran{Stdout: string(raw) + "\n"}
|
||||
}
|
||||
return forgekey.Ran{Status: 1, Err: "not expected here"}
|
||||
}, &seen)
|
||||
run := stepTool(t, m, "build_go_test")
|
||||
if _, err := run(map[string]any{"workspace": ws, "level": LevelIntegration}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st := states.waitEnd(t); st.State != delivery.StepPassed {
|
||||
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
|
||||
}
|
||||
if len(seen) != 1 {
|
||||
t.Fatalf("shut off %d processes, want one: %v", len(seen), seen)
|
||||
}
|
||||
checkShutOff(t, m, seen[0], false)
|
||||
return seen[0]
|
||||
}
|
||||
|
||||
// 1891 (ADR 0533 §1): the integration level is given the Node build module's installed toolchains, read-only, so the
|
||||
// Node build module's own integration tests build with the real tsc and esbuild and reach no registry.
|
||||
func TestTheIntegrationLevelIsGivenTheNodeToolchainsReadOnly(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := integrationShutOffWith(t, dir)
|
||||
if !slices.Contains(s.roBinds, dir) {
|
||||
t.Errorf("the node toolchains %s are not bound read-only: %v", dir, s.roBinds)
|
||||
}
|
||||
if slices.Contains(s.binds, dir) || slices.Contains(s.overlays, dir) {
|
||||
t.Errorf("the node toolchains %s may be written: binds %v, overlays %v", dir, s.binds, s.overlays)
|
||||
}
|
||||
if slices.Contains(s.roBinds, filepath.Dir(dir)) {
|
||||
t.Errorf("the whole cache %s is bound, not its toolchains alone: %v", filepath.Dir(dir), s.roBinds)
|
||||
}
|
||||
}
|
||||
|
||||
// 1891 (ADR 0533 §1): a node where the Node build module has installed no toolchain runs the level as before, with
|
||||
// nothing more bound (bubblewrap binds only what is there).
|
||||
func TestANodeWithoutNodeToolchainsRunsTheLevelWithNothingMoreBound(t *testing.T) {
|
||||
missing := filepath.Join(t.TempDir(), "none")
|
||||
s := integrationShutOffWith(t, missing)
|
||||
if slices.Contains(s.roBinds, missing) {
|
||||
t.Errorf("a missing %s is bound: %v", missing, s.roBinds)
|
||||
}
|
||||
}
|
||||
|
||||
// 1891 (ADR 0533 §1): the unit level is not given them.
|
||||
func TestTheUnitLevelIsNotGivenTheNodeToolchains(t *testing.T) {
|
||||
m, states, ws, _ := workspaceFor(t, "failing")
|
||||
dir := t.TempDir()
|
||||
m.NodeToolchains = dir
|
||||
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 _, s := range seen {
|
||||
if slices.Contains(s.roBinds, dir) || slices.Contains(s.binds, dir) {
|
||||
t.Errorf("%s was given the node toolchains", s.command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1891 (ADR 0533 §1, §2): the place bound is the Node build module's own: its manifest's cache directory joined with
|
||||
// toolchain, and that cache directory lets other accounts enter it without listing it (0711).
|
||||
func TestTheNodeToolchainsAreTheNodeBuildModulesCache(t *testing.T) {
|
||||
if _, err := catalogue.ReadTreeManifest(catalogueRoot, filepath.Join(catalogueRoot, "modules", "mesh-build-node")); err != nil {
|
||||
t.Fatalf("mesh-sdk's validator refuses mesh-build-node's manifest: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(catalogueRoot, "modules", "mesh-build-node", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var man struct {
|
||||
Resources []struct {
|
||||
ID, Path, Mode string
|
||||
} `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &man); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, r := range man.Resources {
|
||||
if r.ID != "cache" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if want := filepath.Join(r.Path, "toolchain"); NodeToolchains != want {
|
||||
t.Errorf("NodeToolchains = %s, want %s (mesh-build-node's cache)", NodeToolchains, want)
|
||||
}
|
||||
mode, err := strconv.ParseUint(r.Mode, 8, 32)
|
||||
if err != nil || mode&0o001 == 0 || mode&0o004 != 0 {
|
||||
t.Errorf("mesh-build-node's cache has mode %q; want others to enter it and not list it (0711)", r.Mode)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("mesh-build-node's manifest declares no cache directory")
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ module meshbuildgo
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9
|
||||
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,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876 h1:dtiIJzrX7ey1wmsdzapiFgCKDllCph7/vxfUp1ib/t8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 h1:Ufmd2cYEXICQ817IGflccP9bexABE4WhIO49HPbaoSo=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9/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=
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
@@ -60,6 +58,11 @@ type Look struct {
|
||||
// missing is a *buildstep.Refusal naming it, so a step ends refused and the health says the same.
|
||||
func (m *Module) look(ctx context.Context) (Look, error) {
|
||||
l := Look{Account: m.Account}
|
||||
// The runtime directory comes first: buildah refuses before anything else when its XDG_RUNTIME_DIR is not there
|
||||
// (issue 1884), so a look that made it only after `buildah --version` never made it at all.
|
||||
if err := m.ensureRuntimeDir(); err != nil {
|
||||
return l, &buildstep.Refusal{Text: err.Error()}
|
||||
}
|
||||
var err error
|
||||
if l.Buildah, err = m.buildahVersion(ctx); err != nil {
|
||||
return l, &buildstep.Refusal{Text: err.Error()}
|
||||
@@ -67,11 +70,6 @@ func (m *Module) look(ctx context.Context) (Look, error) {
|
||||
if l.UIDs, l.GIDs, err = m.ranges(); err != nil {
|
||||
return l, &buildstep.Refusal{Text: err.Error(), Have: l.Buildah}
|
||||
}
|
||||
for _, d := range []string{m.Store, filepath.Join(m.Store, "xdg")} {
|
||||
if err := os.MkdirAll(d, 0o700); err != nil {
|
||||
return l, err
|
||||
}
|
||||
}
|
||||
if err := m.userNamespace(ctx); err != nil {
|
||||
return l, &buildstep.Refusal{Text: err.Error(), Have: l.Buildah}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,25 @@ func (m *Module) storeFlags() []string {
|
||||
// env is what buildah is given beyond the base environment: a runtime directory of its own inside the store, so
|
||||
// nothing is read from the account's runtime directory, and no registry credential file anywhere (the module has none
|
||||
// and REGISTRY_AUTH_FILE is never set).
|
||||
//
|
||||
// Every call makes the runtime directory first if it is not there (issue 1884): a directory removed after the last look
|
||||
// is made again before buildah runs. A failure to make it is not lost: buildah then names the directory in its own
|
||||
// error, and the next look refuses with it.
|
||||
func (m *Module) env() []string {
|
||||
return []string{"XDG_RUNTIME_DIR=" + filepath.Join(m.Store, "xdg")}
|
||||
_ = m.ensureRuntimeDir()
|
||||
return []string{"XDG_RUNTIME_DIR=" + m.runtimeDir()}
|
||||
}
|
||||
|
||||
// runtimeDir is buildah's runtime directory, inside the store.
|
||||
func (m *Module) runtimeDir() string { return filepath.Join(m.Store, "xdg") }
|
||||
|
||||
// ensureRuntimeDir makes the store and the runtime directory inside it, 0700 and under the module's own account (the
|
||||
// process runs as it), when they are not there; it changes nothing that is.
|
||||
func (m *Module) ensureRuntimeDir() error {
|
||||
for _, d := range []string{m.Store, m.runtimeDir()} {
|
||||
if err := os.MkdirAll(d, 0o700); err != nil {
|
||||
return fmt.Errorf("buildah's runtime directory %s cannot be made: %w", m.runtimeDir(), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
// buildahLike refuses as buildah does when the XDG_RUNTIME_DIR it is given is not there (seen on ace, issue 1884):
|
||||
// "lstat <dir>: no such file or directory", before it does anything else.
|
||||
func buildahLike(t *testing.T, run forgekey.Runner, seen *[]string) forgekey.Runner {
|
||||
t.Helper()
|
||||
return func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
||||
if name == "buildah" {
|
||||
for _, e := range env {
|
||||
dir, ok := strings.CutPrefix(e, "XDG_RUNTIME_DIR=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Lstat(dir); err != nil {
|
||||
*seen = append(*seen, "missing")
|
||||
return forgekey.Ran{Status: 125, Stderr: "Error: lstat " + dir + ": no such file or directory\n"}
|
||||
}
|
||||
*seen = append(*seen, "present")
|
||||
}
|
||||
}
|
||||
return run(ctx, env, name, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// 1884/AC1: given a store directory that exists without its runtime directory, when the health is looked at, then the
|
||||
// module makes the runtime directory (0700) before it runs buildah, and the health is healthy.
|
||||
func TestHealthMakesTheRuntimeDirectoryBeforeBuildah(t *testing.T) {
|
||||
f := newFake(t)
|
||||
m := moduleWith(t, f, newStates())
|
||||
var seen []string
|
||||
m.Run = buildahLike(t, m.Run, &seen)
|
||||
xdg := filepath.Join(m.Store, "xdg")
|
||||
if _, err := os.Stat(xdg); !os.IsNotExist(err) {
|
||||
t.Fatalf("the test needs a store without its runtime directory: %v", err)
|
||||
}
|
||||
a := m.Health(context.Background())
|
||||
if a.State != health.StateHealthy || !a.Healthy {
|
||||
t.Fatalf("health = %+v, want healthy", a)
|
||||
}
|
||||
st, err := os.Stat(xdg)
|
||||
if err != nil {
|
||||
t.Fatalf("the runtime directory was not made: %v", err)
|
||||
}
|
||||
if st.Mode().Perm() != 0o700 || !st.IsDir() {
|
||||
t.Errorf("the runtime directory is %v, want a directory 0700", st.Mode())
|
||||
}
|
||||
for _, s := range seen {
|
||||
if s != "present" {
|
||||
t.Fatalf("buildah ran without its runtime directory: %v", seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1884/AC2: given a module whose runtime directory was removed after its last look, when a build step runs buildah, then
|
||||
// the runtime directory is made again before buildah runs.
|
||||
func TestEveryBuildahRunMakesTheRuntimeDirectoryAgain(t *testing.T) {
|
||||
f := newFake(t)
|
||||
m := moduleWith(t, f, newStates())
|
||||
var seen []string
|
||||
m.Run = buildahLike(t, m.Run, &seen)
|
||||
if err := m.Check(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(m.Store, "xdg")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen = nil
|
||||
m.remove("localhost/x:y")
|
||||
if len(seen) != 1 || seen[0] != "present" {
|
||||
t.Fatalf("buildah's runs saw their runtime directory %v, want [present]", seen)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ module meshbuildimage
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876 h1:dtiIJzrX7ey1wmsdzapiFgCKDllCph7/vxfUp1ib/t8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 h1:Ufmd2cYEXICQ817IGflccP9bexABE4WhIO49HPbaoSo=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -3,18 +3,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
// realMachine installs the real toolchain from the module's own lock (npm ci --ignore-scripts, from the mesh's
|
||||
// registry for @novox, which reads without a credential) and makes two workspaces holding one fixture bundle.
|
||||
// realMachine gives the tests the real toolchain of the module's own lock and makes two workspaces holding one fixture
|
||||
// bundle. The toolchain is the one this node's installed Node build module keeps (ADR 0533 §1): the integration level
|
||||
// is given its toolchains read-only and no network, so it is copied into the test's own cache from there. Only where
|
||||
// no toolchain is installed (a desk) is it installed with `npm ci --ignore-scripts` from the mesh's registry, as the
|
||||
// module does, which needs the network.
|
||||
func realMachine(t *testing.T) (*Module, string) {
|
||||
t.Helper()
|
||||
for _, p := range []string{"node", "npm", "bwrap"} { // bwrap: tsc, esbuild and the scripts run shut off (1309)
|
||||
@@ -40,8 +47,11 @@ func realMachine(t *testing.T) (*Module, string) {
|
||||
return Registry{URL: "https://git.novox.be/api/packages/novox/npm/"}, nil
|
||||
}}}
|
||||
withStore(t, m)
|
||||
if from := installedToolchain(t, filepath.Join(CacheDir, "toolchain"), filepath.Join(home, "toolchain", "package-lock.json")); from != "" {
|
||||
copyToolchain(t, from, m.Toolchain.Paths.Dir(m.Recipe.Version()))
|
||||
}
|
||||
if err := m.Toolchain.Ensure(context.Background(), m.Recipe.Version()); err != nil {
|
||||
t.Fatal(err)
|
||||
t.Fatalf("no toolchain of this lock is installed under %s, and installing it from the registry failed: %v", filepath.Join(CacheDir, "toolchain"), err)
|
||||
}
|
||||
for _, id := range []string{"w1", "w2"} {
|
||||
for p, body := range map[string]string{
|
||||
@@ -115,3 +125,74 @@ func TestAPackageWhoseTestsImportItsBuildPassesTheUnitLevel(t *testing.T) {
|
||||
t.Errorf("result = %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// installedToolchain is the toolchain directory under toolchains that this node's Node build module installed: the
|
||||
// one installed from the lock at lockPath, byte for byte, or, when the change carries a lock no delivered release has
|
||||
// installed yet, the one installed last, said in the test's log (ADR 0533 §3: a new lock is proven by the module that
|
||||
// installs it on its first node). "" when none is there or the directory cannot be read: no Node build module here.
|
||||
func installedToolchain(t *testing.T, toolchains, lockPath string) string {
|
||||
t.Helper()
|
||||
want, err := os.ReadFile(lockPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, err := os.ReadDir(toolchains)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
last, lastAt := "", time.Time{}
|
||||
for _, e := range entries {
|
||||
dir := filepath.Join(toolchains, e.Name())
|
||||
mark, err := os.Stat(filepath.Join(dir, installedMark))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(dir, "package-lock.json"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if bytes.Equal(got, want) {
|
||||
t.Logf("the toolchain is this node's installed %s, of this change's lock", dir)
|
||||
return dir
|
||||
}
|
||||
if mark.ModTime().After(lastAt) {
|
||||
last, lastAt = dir, mark.ModTime()
|
||||
}
|
||||
}
|
||||
if last != "" {
|
||||
t.Logf("NOT THIS LOCK: no toolchain of this change's lock is installed here; the tests run with the one installed last, %s", last)
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
// copyToolchain copies the installed toolchain from into to whole, files and folders with their modes, so the test's
|
||||
// steps run behind a toolchain of their own and the installed one is only read.
|
||||
func copyToolchain(t *testing.T, from, to string) {
|
||||
t.Helper()
|
||||
err := filepath.WalkDir(from, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(from, p)
|
||||
dst := filepath.Join(to, rel)
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case d.IsDir():
|
||||
return os.MkdirAll(dst, 0o755)
|
||||
case info.Mode().IsRegular():
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(dst, raw, info.Mode().Perm()|0o600)
|
||||
default:
|
||||
return fmt.Errorf("%s is neither a file nor a folder", p)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("copying the installed toolchain %s: %v", from, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -53,14 +54,17 @@ func (t Toolchain) Present(version string) bool {
|
||||
// are copied in, and `npm ci --ignore-scripts` runs once, the mesh's registry named for the @novox scope. A directory
|
||||
// an earlier install left unfinished is removed first.
|
||||
func (t Toolchain) Ensure(ctx context.Context, version string) error {
|
||||
if t.Present(version) {
|
||||
return nil
|
||||
}
|
||||
dir := t.Paths.Dir(version)
|
||||
if t.Present(version) {
|
||||
if opened(dir) {
|
||||
return nil
|
||||
}
|
||||
return t.open(dir)
|
||||
}
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range []string{"package.json", "package-lock.json"} {
|
||||
@@ -76,18 +80,95 @@ func (t Toolchain) Ensure(ctx context.Context, version string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
npmrc := filepath.Join(dir, ".npmrc-install")
|
||||
if err := os.WriteFile(npmrc, []byte(reg.Npmrc()), 0o600); err != nil {
|
||||
// The credential is written in the account's own home (0700), never beneath the cache, whose toolchains are
|
||||
// opened to every account (ADR 0533 §2), and removed as soon as npm has read it.
|
||||
f, err := os.CreateTemp(t.Paths.Home, ".npmrc-install-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
npmrc := f.Name()
|
||||
defer os.Remove(npmrc)
|
||||
_, err = f.WriteString(reg.Npmrc())
|
||||
if cerr := f.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ran := t.Run(ctx, []string{"PATH=" + pathOrDefault(), "HOME=" + t.Paths.Home, "LC_ALL=C", "npm_config_cache=" + filepath.Join(t.Paths.Cache, "npm")},
|
||||
"npm", "ci", "--ignore-scripts", "--no-bin-links", "--no-audit", "--no-fund", "--prefix", dir, "--userconfig", npmrc)
|
||||
os.Remove(npmrc)
|
||||
if ran.Err != "" || ran.Status != 0 {
|
||||
return fmt.Errorf("npm ci --ignore-scripts for the toolchain %s failed (status %d %s): %s",
|
||||
version, ran.Status, ran.Err, tail(ran.Stderr))
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, installedMark), []byte(version+"\n"), 0o600)
|
||||
if err := os.WriteFile(filepath.Join(dir, installedMark), []byte(version+"\n"), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return t.open(dir)
|
||||
}
|
||||
|
||||
// refuseNpmConfig refuses a toolchain folder that holds an install's npm config file (one a dead install of an older release left with
|
||||
// the registry credential in it), naming it, before anything of the folder is opened.
|
||||
func refuseNpmConfig(dir string) error {
|
||||
return filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() && strings.HasPrefix(d.Name(), ".npmrc-install") {
|
||||
return fmt.Errorf("the toolchain %s holds the npm config file %s, which may hold the registry credential, "+
|
||||
"so it is not opened to other accounts; remove it", dir, p)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// opened says whether the toolchain directory dir was opened to other accounts: its installed mark, opened last, is
|
||||
// readable by others.
|
||||
func opened(dir string) bool {
|
||||
st, err := os.Stat(filepath.Join(dir, installedMark))
|
||||
return err == nil && st.Mode().Perm()&0o004 != 0
|
||||
}
|
||||
|
||||
// open lets every account read the toolchain directory dir and nobody but its owner write it (ADR 0533 §2): the
|
||||
// toolchains folder and every folder beneath dir 0755, every file 0644 and every program 0755, whatever umask npm ran
|
||||
// under; the installed mark last, so a toolchain opened only in part is opened again. The other build module's
|
||||
// integration level reads it through the cache directory, which others may enter and not list (0711); the npm cache
|
||||
// beside it stays the owner's.
|
||||
func (t Toolchain) open(dir string) error {
|
||||
if err := refuseNpmConfig(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(filepath.Join(t.Paths.Cache, "toolchain"), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
mark := filepath.Join(dir, installedMark)
|
||||
err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == mark {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch mode := info.Mode(); {
|
||||
case mode.IsDir():
|
||||
return os.Chmod(p, 0o755)
|
||||
case mode.IsRegular() && mode.Perm()&0o100 != 0:
|
||||
return os.Chmod(p, 0o755)
|
||||
case mode.IsRegular():
|
||||
return os.Chmod(p, 0o644)
|
||||
default:
|
||||
return fmt.Errorf("%s is neither a file nor a folder, and a toolchain holds only those", p)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("the toolchain %s cannot be opened to the integration level: %w", dir, err)
|
||||
}
|
||||
return os.Chmod(mark, 0o644)
|
||||
}
|
||||
|
||||
func tail(s string) string {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
// npmAsUmask077 installs as npm does under a umask of 077: node_modules with a package whose folders are 0700, its
|
||||
// file 0600 and its program 0700.
|
||||
func npmAsUmask077(f *fakeNpm) forgekey.Runner {
|
||||
return func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
||||
ran := f.run(ctx, env, name, args...)
|
||||
for i, a := range args {
|
||||
if a == "--prefix" {
|
||||
pkg := filepath.Join(args[i+1], "node_modules", "typescript", "bin")
|
||||
os.MkdirAll(pkg, 0o700)
|
||||
os.WriteFile(filepath.Join(args[i+1], "node_modules", "typescript", "package.json"), []byte("{}"), 0o600)
|
||||
os.WriteFile(filepath.Join(pkg, "tsc"), []byte("#!/bin/sh\n"), 0o700)
|
||||
}
|
||||
}
|
||||
return ran
|
||||
}
|
||||
}
|
||||
|
||||
// checkOpen fails unless every folder from the cache's toolchain down lets others enter and read it and every file lets
|
||||
// others read it, a program others run, and nothing lets anyone but the owner write.
|
||||
func checkOpen(t *testing.T, tc Toolchain) {
|
||||
t.Helper()
|
||||
root := filepath.Join(tc.Paths.Cache, "toolchain")
|
||||
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mode := info.Mode().Perm()
|
||||
switch {
|
||||
case mode&0o022 != 0:
|
||||
t.Errorf("%s is %o: writable by others than its owner", p, mode)
|
||||
case d.IsDir() && mode&0o005 != 0o005:
|
||||
t.Errorf("%s is %o: others may not enter and read it", p, mode)
|
||||
case !d.IsDir() && mode&0o004 == 0:
|
||||
t.Errorf("%s is %o: others may not read it", p, mode)
|
||||
case !d.IsDir() && mode&0o100 != 0 && mode&0o001 == 0:
|
||||
t.Errorf("%s is %o: a program others may not run", p, mode)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1891 (ADR 0533 §2): a toolchain the module installs can be read by the other build module's integration level:
|
||||
// its folders 0755 and its files 0644 (programs 0755), whatever umask npm ran under, and writable by its owner alone.
|
||||
func TestAnInstalledToolchainIsReadableByOthersAndWritableByItsOwnerAlone(t *testing.T) {
|
||||
fake := &fakeNpm{}
|
||||
tc := toolchainIn(t, fake)
|
||||
tc.Run = npmAsUmask077(fake)
|
||||
npm := filepath.Join(tc.Paths.Cache, "npm")
|
||||
if err := os.MkdirAll(npm, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tc.Ensure(context.Background(), "v1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkOpen(t, tc)
|
||||
if st, err := os.Stat(npm); err != nil || st.Mode().Perm() != 0o700 {
|
||||
t.Errorf("the npm download cache beside the toolchains is %v (%v), want 0700: only the toolchains are opened", st.Mode().Perm(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1891 (ADR 0533 §2): a toolchain installed before this release, shut to its owner, is opened by the next Ensure
|
||||
// without installing it again.
|
||||
func TestAToolchainInstalledShutIsOpenedWithoutAnInstall(t *testing.T) {
|
||||
fake := &fakeNpm{}
|
||||
tc := toolchainIn(t, fake)
|
||||
dir := tc.Paths.Dir("v1")
|
||||
os.MkdirAll(filepath.Join(dir, "node_modules", "esbuild", "bin"), 0o700)
|
||||
os.WriteFile(filepath.Join(dir, "node_modules", "esbuild", "bin", "esbuild"), []byte("x"), 0o700)
|
||||
os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte("{}"), 0o600)
|
||||
os.WriteFile(filepath.Join(dir, installedMark), []byte("v1\n"), 0o600)
|
||||
os.Chmod(filepath.Join(tc.Paths.Cache, "toolchain"), 0o700)
|
||||
if err := tc.Ensure(context.Background(), "v1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Errorf("npm ran %d times for a toolchain already there", len(fake.calls))
|
||||
}
|
||||
checkOpen(t, tc)
|
||||
}
|
||||
|
||||
// 1891 (ADR 0533 §2): the cache directory lets other accounts enter it and not list it, so the toolchains beneath it
|
||||
// can be reached by their path and the npm cache beside them (0700) cannot.
|
||||
func TestTheCacheDirectoryIsEnteredByOthersNotListed(t *testing.T) {
|
||||
r := resource(t, readManifest(t), "cache")
|
||||
if r["mode"] != "0711" {
|
||||
t.Errorf("the cache directory's mode is %v, want 0711", r["mode"])
|
||||
}
|
||||
}
|
||||
|
||||
// 1891 review (ADR 0533 §2): the registry credential npm is told is never written beneath the cache, so opening a
|
||||
// toolchain can never open it: npm reads it from a file in the account's own home, readable by its owner alone, and
|
||||
// the file is gone when Ensure returns.
|
||||
func TestTheInstallCredentialIsNeverInTheFolderThatIsOpened(t *testing.T) {
|
||||
fake := &fakeNpm{}
|
||||
tc := toolchainIn(t, fake)
|
||||
var userconfig string
|
||||
inner := npmAsUmask077(fake)
|
||||
tc.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
||||
for i, a := range args {
|
||||
if a == "--userconfig" {
|
||||
userconfig = args[i+1]
|
||||
}
|
||||
}
|
||||
if st, err := os.Stat(userconfig); err != nil || st.Mode().Perm() != 0o600 {
|
||||
t.Errorf("the credential file %s is %v (%v), want 0600", userconfig, st.Mode().Perm(), err)
|
||||
}
|
||||
return inner(ctx, env, name, args...)
|
||||
}
|
||||
if err := tc.Ensure(context.Background(), "v1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rel, err := filepath.Rel(tc.Paths.Home, userconfig); err != nil || rel != filepath.Base(userconfig) {
|
||||
t.Errorf("the credential file %s is not in the account's home %s", userconfig, tc.Paths.Home)
|
||||
}
|
||||
if _, err := os.Stat(userconfig); err == nil {
|
||||
t.Errorf("the credential file %s is left after the install", userconfig)
|
||||
}
|
||||
filepath.WalkDir(tc.Paths.Cache, func(p string, d fs.DirEntry, err error) error {
|
||||
if err == nil && strings.Contains(d.Name(), "npmrc") {
|
||||
t.Errorf("a credential file %s lies beneath the cache", p)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// 1891 review (ADR 0533 §2): open refuses a toolchain folder that holds an npm config file, naming it, and opens
|
||||
// nothing of it, so a credential left by a dead install is never made readable.
|
||||
func TestOpenRefusesAFolderHoldingAnNpmConfig(t *testing.T) {
|
||||
fake := &fakeNpm{}
|
||||
tc := toolchainIn(t, fake)
|
||||
dir := tc.Paths.Dir("v1")
|
||||
os.MkdirAll(filepath.Join(dir, "node_modules"), 0o700)
|
||||
left := filepath.Join(dir, ".npmrc-install")
|
||||
os.WriteFile(left, []byte("//x/:_auth=secret\n"), 0o600)
|
||||
os.WriteFile(filepath.Join(dir, installedMark), []byte("v1\n"), 0o600)
|
||||
err := tc.Ensure(context.Background(), "v1")
|
||||
if err == nil || !strings.Contains(err.Error(), left) {
|
||||
t.Errorf("Ensure = %v, want a refusal naming %s", err, left)
|
||||
}
|
||||
if st, _ := os.Stat(left); st.Mode().Perm() != 0o600 {
|
||||
t.Errorf("the credential file was opened to %o", st.Mode().Perm())
|
||||
}
|
||||
if st, _ := os.Stat(dir); st.Mode().Perm()&0o005 != 0 {
|
||||
t.Errorf("the folder holding it was opened to %o", st.Mode().Perm())
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ module meshbuildnode
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9
|
||||
github.com/distribution/distribution/v3 v3.1.2
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876 h1:dtiIJzrX7ey1wmsdzapiFgCKDllCph7/vxfUp1ib/t8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 h1:Ufmd2cYEXICQ817IGflccP9bexABE4WhIO49HPbaoSo=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9/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=
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
"id": "cache",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh-build-node-cache",
|
||||
"mode": "0700",
|
||||
"mode": "0711",
|
||||
"owner": "mesh-build-node",
|
||||
"unknown": "program"
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ module mesh-delivery-gate
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876 h1:dtiIJzrX7ey1wmsdzapiFgCKDllCph7/vxfUp1ib/t8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 h1:Ufmd2cYEXICQ817IGflccP9bexABE4WhIO49HPbaoSo=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -120,7 +120,7 @@ type Client struct {
|
||||
// Now dates the reports; nil is time.Now.
|
||||
Now func() time.Time
|
||||
// SocketUp wakes the client when the node-engine's write socket is made again (watchSocket); nil never does.
|
||||
// A module whose write failed for want of the socket is filled again then (issue 1165), and nothing else is.
|
||||
// A module whose write failed is filled again then (issue 1165, issue 1873), and nothing else is.
|
||||
SocketUp <-chan struct{}
|
||||
|
||||
// watchBound watches a provider's bucket of entries for this machine's keys, once per run (consumers.go); set by
|
||||
@@ -150,7 +150,8 @@ type state struct {
|
||||
// of the consumers left out of one of its files.
|
||||
providing map[string]bool
|
||||
leftOut map[string]string
|
||||
// unplaced is a module whose last fill failed for want of the node-engine's write socket (issue 1165).
|
||||
// unplaced is a module whose last fill failed, for want of the node-engine's write socket (issue 1165) or by the
|
||||
// engine's own refusal (issue 1873): filled again when the socket is made again.
|
||||
unplaced map[string]bool
|
||||
// bound is each provider's bucket of entries this client watches, by key: this machine's consumers' entries alone;
|
||||
// boundReady is a bucket whose current entries are all read, and boundUsers the modules filled from each.
|
||||
@@ -705,9 +706,10 @@ func (c *Client) reconcile(ctx context.Context, kvs map[string]jetstream.KeyValu
|
||||
err = errFill(a)
|
||||
}
|
||||
if err != nil {
|
||||
if socketAbsent(err) {
|
||||
s.unplaced[module] = true
|
||||
}
|
||||
// Filled again when a node-engine makes its socket again: one gone (issue 1165), or one that refused for a
|
||||
// reason of its own, which a newer engine may write (issue 1873: "is a directory", what a container
|
||||
// runtime left at the path, cleared by the engine that followed).
|
||||
s.unplaced[module] = true
|
||||
failed = append(failed, fmt.Sprintf("%s: %v", f.Path, err))
|
||||
continue
|
||||
}
|
||||
|
||||
+35
@@ -106,3 +106,38 @@ func TestWatchSocketWakesWhenTheSocketIsMadeAndWhenItsDirectoryReturns(t *testin
|
||||
defer l.Close()
|
||||
heard("the directory and socket back")
|
||||
}
|
||||
|
||||
// Issue 1873: a fill the node-engine refused for a reason of its own (on ace, "read …/superuser.secret: is a
|
||||
// directory", what a container runtime had left there) is tried again when a node-engine makes its socket again, as a
|
||||
// newer engine may write what the older could not. Before, only a fill that failed for want of the socket was, so
|
||||
// ace's postgres stayed unplaced after the engine that cures it was delivered, and its old failure was said every 30 s.
|
||||
func TestNatsAFillTheEngineFailedIsTriedWhenTheSocketReturns(t *testing.T) {
|
||||
b := startBus(t)
|
||||
heard := b.listen(t)
|
||||
e := newEngine(t, map[string]string{dunstrc: "owned"})
|
||||
if err := os.MkdirAll(e.at(dunstrc), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stop := e.listen(t)
|
||||
b.config(t, "dunst", "font-size", "13")
|
||||
b.assign(t, "dunst", dunst(t))
|
||||
aClient(t, b, e, t.TempDir())
|
||||
|
||||
eventually(t, "the engine's failure is said", func() bool {
|
||||
r, _ := heard.last("dunst")
|
||||
return r.Status == StatusFailed && strings.Contains(r.Reason, "is a directory")
|
||||
})
|
||||
// The node-engine replaces itself with one that clears what the runtime left.
|
||||
stop()
|
||||
if err := os.Remove(e.at(dunstrc)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.listen(t)
|
||||
eventually(t, "dunstrc is written by the new engine", func() bool {
|
||||
return strings.Contains(e.read(t, dunstrc), "Monospace 13")
|
||||
})
|
||||
eventually(t, "and the report says applied", func() bool {
|
||||
r, _ := heard.last("dunst")
|
||||
return r.Status == StatusApplied
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# The test script compiles the sources beside themselves before the tests import them (package.json, issue 1891).
|
||||
*.js
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
|
||||
"test": "tsc --module NodeNext --moduleResolution NodeNext --target ES2022 --types node --rootDir . --outDir . $(find . -name '*.ts' ! -name '*.d.ts' ! -path './test/*' ! -path './node_modules/*' ! -path './dist/*') && node --test --experimental-strip-types 'test/*.test.ts'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.7"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc topics.ts client.ts index.ts tools/index.ts provisioner/index.ts bootstrap/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
|
||||
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.1"
|
||||
|
||||
@@ -83,7 +83,7 @@ func TestTheKeeperIsAProcessUnderItsOwnAccountWithABusAccountOfItsOwn(t *testing
|
||||
t.Errorf("the bus account is the keeper's own secret, owned by its account: %v", secrets)
|
||||
}
|
||||
state := m.resource(t, "state")
|
||||
if state["type"] != "directory" || state["place"] != "." || state["unknown"] == nil {
|
||||
if state["type"] != "directory" || state["place"] != "mesh" || state["unknown"] == nil {
|
||||
t.Errorf("the state directory: %v", state)
|
||||
}
|
||||
order := map[string]int{}
|
||||
@@ -211,3 +211,37 @@ func TestUpgradeOrderIsATrustedListOfTheMesh(t *testing.T) {
|
||||
t.Error("a value falls back only to a key of its own type (ADR 0348 §4)")
|
||||
}
|
||||
}
|
||||
|
||||
// 1841/AC17: the keeper's state directory is the mesh's directory for the module (place "mesh", <data root>/mesh/pacman),
|
||||
// never place "." or a placement by default: for this module both resolve under /var/lib/pacman, the package manager's
|
||||
// own database directory (issue 1841: the node-engine used it as found and reported it unhealthy, and wrote keeper.json
|
||||
// into it). What the keeper and its root helper keep there is still named through ${dir:state}.
|
||||
func TestTheStateDirectoryIsOutsideThePackageManagersOwn(t *testing.T) {
|
||||
m := rawManifest(t)
|
||||
resources, _ := m["resources"].([]any)
|
||||
var state map[string]any
|
||||
for _, x := range resources {
|
||||
if r, _ := x.(map[string]any); r["id"] == "state" {
|
||||
state = r
|
||||
}
|
||||
}
|
||||
if state == nil || state["type"] != "directory" {
|
||||
t.Fatalf("no state directory: %v", state)
|
||||
}
|
||||
if state["place"] != "mesh" || state["path"] != nil {
|
||||
t.Fatalf("the state directory says place %v and path %v; it is placed \"mesh\" with no path, so it resolves to "+
|
||||
"/var/lib/mesh/pacman and not into /var/lib/pacman", state["place"], state["path"])
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(raw, []byte("/var/lib/pacman")) {
|
||||
t.Fatal("the manifest names /var/lib/pacman")
|
||||
}
|
||||
for _, want := range []string{"${dir:state}/keeper.json", "${dir:state}/broker-keeper.secret", RootSocketPath} {
|
||||
if !bytes.Contains(raw, []byte(want)) {
|
||||
t.Errorf("the manifest no longer names %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,12 +248,12 @@ func tools(m *Machine) []stdio.Tool {
|
||||
Name: "pacman_upgrades",
|
||||
Description: "Every full upgrade and rollback of every node, from the bus (ADR 0508 §1): each run's node, kind, what started it, " +
|
||||
"its round, when it began and ended, its outcome (refused, finished, failed or running) and whether its after-upgrade check passed; " +
|
||||
"narrowed to a node and to days, newest first. With a node and a run, that run whole, every step with its time.",
|
||||
"narrowed to a node and to days, newest first. With a run, that run whole, every step with its time: the node's named, else this one's.",
|
||||
Input: schema(map[string]any{
|
||||
"node": map[string]any{"type": "string", "description": "only this node's runs"},
|
||||
"from": sinceArg,
|
||||
"until": map[string]any{"type": "string", "description": "a day, YYYY-MM-DD, the last one listed"},
|
||||
"run": map[string]any{"type": "string", "description": "one run, by its id, with node"},
|
||||
"run": map[string]any{"type": "string", "description": "one run, by its id: of node, else of this node"},
|
||||
"limit": limitArg(50, 1000),
|
||||
}),
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
@@ -269,6 +269,11 @@ func tools(m *Machine) []stdio.Tool {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if v[3] != "" && v[0] == "" {
|
||||
// An address naming the machine has its node taken out of the arguments (the console):
|
||||
// the run is this machine's (issue 1841).
|
||||
v[0] = thisNode()
|
||||
}
|
||||
return ListUpgrades(upgradeState(StateRuns), v[0], v[1], v[2], v[3], n)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -57,7 +57,7 @@ func TestPacmanConfIsWholeTheUnionOfRepositoriesAndTheImprovedOptions(t *testing
|
||||
t.Skip("pacman-conf is not installed here; the rendered file is not proven")
|
||||
}
|
||||
file := filepath.Join(t.TempDir(), "pacman.conf")
|
||||
if err := os.WriteFile(file, []byte(content), 0o644); err != nil {
|
||||
if err := os.WriteFile(file, []byte(provenConf(t, content, filepath.Dir(file))), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos, err := exec.Command(conf, "--config", file, "--repo-list").CombinedOutput()
|
||||
@@ -74,6 +74,35 @@ func TestPacmanConfIsWholeTheUnionOfRepositoriesAndTheImprovedOptions(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
// Every repository of the shipped file reads the mirror list reflector writes, and the file pacman-conf proves reads
|
||||
// nothing of the machine: the unit level runs shut off (novox/hq ADR 0448), where /etc/pacman.d is not there
|
||||
// (issue 1891).
|
||||
func TestTheProvenConfReadsNothingOfTheMachine(t *testing.T) {
|
||||
content := templateText(t, manifest(t).resource(t, "config"))
|
||||
if n := strings.Count(content, "Include = "+Mirrorlist+"\n"); n != 3 || strings.Count(content, "Include") != n {
|
||||
t.Fatalf("every repository reads %s, and nothing else is included: %d of 3\n%s", Mirrorlist, n, content)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
proven := provenConf(t, content, dir)
|
||||
if strings.Contains(proven, "/etc/") {
|
||||
t.Fatalf("the proven file still names the machine's /etc:\n%s", proven)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "mirrorlist")); err != nil {
|
||||
t.Fatalf("the proven file's mirror list is not in its directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// provenConf is the shipped pacman.conf with its repositories reading a mirror list of its own in dir, made there,
|
||||
// so pacman-conf proves the file the module ships and reads nothing of the machine.
|
||||
func provenConf(t *testing.T, content, dir string) string {
|
||||
t.Helper()
|
||||
list := filepath.Join(dir, "mirrorlist")
|
||||
if err := os.WriteFile(list, []byte("Server = https://mirror.example/$repo/os/$arch\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return strings.ReplaceAll(content, "Include = "+Mirrorlist, "Include = "+list)
|
||||
}
|
||||
|
||||
func TestReflectorWritesTheListPacmanReads(t *testing.T) {
|
||||
content := templateText(t, manifest(t).resource(t, "mirrors"))
|
||||
opts := map[string]string{}
|
||||
|
||||
@@ -228,7 +228,7 @@ func (u *Upgrades) StartRollback(of string, removeAdded bool, ask string) (Run,
|
||||
if o.Run.Run == r.Run.Run {
|
||||
return r.Run, fmt.Errorf("run %s of %s is there already: one run a second", o.Run.Run, u.Node)
|
||||
}
|
||||
if !ended(o.Run) && o.From != FromLog {
|
||||
if holdsTheDatabase(o.Run) {
|
||||
return r.Run, u.refuse(r, fmt.Sprintf("run %s is still going (its last step is %s): one run at a time", o.Run.Run, lastStep(o.Run)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package main
|
||||
|
||||
// A run read from pacman's log, or a run whose transaction ended, never holds a new run back (issue 1841, the
|
||||
// refusal of 2026-10-22 22:00 on the control-node: "run 20220123T194900Z is still going (its last step is finished): one
|
||||
// run at a time"); and pacman_upgrades reads one run whole at an address that names the machine.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// installLogHead is the first twelve lines of the control-node's /var/log/pacman.log, captured 2026-10-22 22:10
|
||||
// with node-reads.read: the install's `pacman -Syu` with nothing to do, which became run 20220123T194900Z, its
|
||||
// last step finished at the moment it began (pacman_upgrades there, 2026-10-22: from log, outcome finished,
|
||||
// began 2022-01-23T19:49:00Z, ended 2022-01-23T20:49:00+01:00).
|
||||
const installLogHead = "[2022-01-23T20:49:00+0100] [PACMAN] Running 'pacman -Syu'\n" +
|
||||
"[2022-01-23T20:49:00+0100] [PACMAN] synchronizing package lists\n" +
|
||||
"[2022-01-23T20:49:00+0100] [PACMAN] starting full system upgrade\n" +
|
||||
"[2022-01-23T20:50:45+0100] [PACMAN] Running 'pacman -S nano'\n" +
|
||||
"[2022-01-23T20:50:49+0100] [ALPM] transaction started\n" +
|
||||
"[2022-01-23T20:50:50+0100] [ALPM] installed nano (6.0-1)\n" +
|
||||
"[2022-01-23T20:50:50+0100] [ALPM] transaction completed\n" +
|
||||
"[2022-01-23T20:50:50+0100] [ALPM] running '30-systemd-update.hook'...\n" +
|
||||
"[2022-01-23T20:55:31+0100] [PACMAN] Running 'pacman -S docker'\n" +
|
||||
"[2022-01-23T20:55:36+0100] [ALPM] transaction started\n" +
|
||||
"[2022-01-23T20:55:36+0100] [ALPM] installed bridge-utils (1.7.1-1)\n" +
|
||||
"[2022-01-23T20:55:36+0100] [ALPM] installed libtool (2.4.6+42+gb88cebd5-16)\n"
|
||||
|
||||
// 1841/AC20: given the run read from the control-node's log, when an upgrade is asked, then it starts.
|
||||
func TestARunReadFromTheLogIsHistoryAndNeverHoldsAnAsk(t *testing.T) {
|
||||
k := newKeeperBed(t)
|
||||
past := PastUpgrades("node-a", installLogHead)
|
||||
if len(past) != 1 || past[0].Run != "20220123T194900Z" || steps(past[0]) != "started finished" || past[0].From != FromLog {
|
||||
t.Fatalf("the captured log reads as the record the control-node holds: %+v", past)
|
||||
}
|
||||
if _, err := k.runs.Create(NodeKey("node-a", past[0].Run), past[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := k.u.StartUpgrade(FromAsk, "", "a1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var r Run
|
||||
k.runs.value("node-a.20261004T120000Z", &r)
|
||||
if lastStep(r) == StepRefused || len(k.root.started) != 1 {
|
||||
t.Errorf("a run read from the log held the ask back: %s %+v", steps(r), r.Steps)
|
||||
}
|
||||
}
|
||||
|
||||
// 1841/AC21: given a run the mesh ran whose last step is finished, failed or refused (no after-upgrade check
|
||||
// yet, no end time of the run itself), when another run would start, then the earlier one does not hold it;
|
||||
// a run whose transaction may still go on (no step, recorded, checked before, started) does.
|
||||
func TestARunWhoseTransactionEndedNeverHoldsTheNext(t *testing.T) {
|
||||
at := time.Date(2026, 10, 22, 20, 0, 0, 0, time.UTC)
|
||||
for last, holds := range map[string]bool{
|
||||
StepFinished: false, StepFailed: false, StepRefused: false, StepCheckedAfter: false,
|
||||
"": true, StepRecorded: true, StepCheckedBefore: true, StepStarted: true,
|
||||
} {
|
||||
r := Run{Node: "node-a", Run: "20261022T200000Z", Kind: RunUpgrade, From: FromAsk}
|
||||
if last != "" {
|
||||
r.Steps = []Step{{Step: last, At: at}}
|
||||
}
|
||||
if got := holdsTheDatabase(r); got != holds {
|
||||
t.Errorf("last step %q: holds %v, want %v", last, got, holds)
|
||||
}
|
||||
r.From = FromLog
|
||||
if holdsTheDatabase(r) {
|
||||
t.Errorf("a run read from the log, last step %q, holds the next", last)
|
||||
}
|
||||
}
|
||||
|
||||
k := newKeeperBed(t)
|
||||
done := Run{Node: "node-a", Run: "20261004T110000Z", Kind: RunUpgrade, From: FromAsk,
|
||||
Steps: []Step{{Step: StepStarted, At: k.now}, {Step: StepFinished, At: k.now}}}
|
||||
if _, err := k.runs.Create(NodeKey("node-a", done.Run), done); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := k.u.StartUpgrade(FromAsk, "", "a1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var r Run
|
||||
k.runs.value("node-a.20261004T120000Z", &r)
|
||||
if lastStep(r) == StepRefused {
|
||||
t.Errorf("a finished run held the next: %+v", r.Steps)
|
||||
}
|
||||
}
|
||||
|
||||
// 1841/AC22: given an address that names the machine (the console takes `node` out of the arguments), when
|
||||
// pacman_upgrades is asked for one run, then that machine's run is answered whole.
|
||||
func TestPacmanUpgradesReadsOneRunOfThisNodeWithoutANode(t *testing.T) {
|
||||
runs := newBucket()
|
||||
past := PastUpgrades("node-a", installLogHead)[0]
|
||||
if _, err := runs.Create(NodeKey("node-a", past.Run), past); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
savedState, savedNode := upgradeState, thisNode
|
||||
upgradeState = func(string) Bucket { return runs }
|
||||
thisNode = func() string { return "node-a" }
|
||||
defer func() { upgradeState, thisNode = savedState, savedNode }()
|
||||
for _, tool := range tools(machine(nil, 1000)) {
|
||||
if tool.Name != "pacman_upgrades" {
|
||||
continue
|
||||
}
|
||||
out, err := tool.Run(map[string]any{"run": past.Run})
|
||||
if err != nil {
|
||||
t.Fatalf("one run without a node: %v", err)
|
||||
}
|
||||
whole := out.(map[string]any)["run"].(Run)
|
||||
if whole.Node != "node-a" || steps(whole) != "started finished" || !strings.Contains(whole.Steps[1].Why, "no transaction") {
|
||||
t.Errorf("one run whole: %+v", whole)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("no pacman_upgrades")
|
||||
}
|
||||
@@ -63,6 +63,22 @@ func ended(r Run) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// holdsTheDatabase says whether a run holds a new run back (one run at a time, ADR 0508 §8): only a run the
|
||||
// mesh ran whose transaction may still go on. A run read from pacman's log is history (ADR 0508 §1), and a run
|
||||
// whose last step is finished, failed, refused or checked after has no transaction left: its after-upgrade
|
||||
// check, still to be written by Follow, reads and changes nothing (issue 1841: every run read from the log
|
||||
// ended at finished and held every ask back).
|
||||
func holdsTheDatabase(r Run) bool {
|
||||
if r.From == FromLog {
|
||||
return false
|
||||
}
|
||||
switch lastStep(r) {
|
||||
case StepFinished, StepFailed, StepRefused, StepCheckedAfter:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ownRuns are the node's runs, oldest first, each with the revision of its key.
|
||||
func (u *Upgrades) ownRuns() ([]openRun, error) {
|
||||
keys, err := u.Runs.Keys()
|
||||
@@ -117,7 +133,7 @@ func (u *Upgrades) startUpgrade(r *openRun) error {
|
||||
if o.Run.Run == r.Run.Run {
|
||||
return fmt.Errorf("run %s of %s is there already: one run a second", o.Run.Run, u.Node)
|
||||
}
|
||||
if !ended(o.Run) {
|
||||
if holdsTheDatabase(o.Run) {
|
||||
return u.refuse(r, fmt.Sprintf("run %s is still going (its last step is %s): one run at a time", o.Run.Run, lastStep(o.Run)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ module pacman
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876 h1:dtiIJzrX7ey1wmsdzapiFgCKDllCph7/vxfUp1ib/t8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 h1:Ufmd2cYEXICQ817IGflccP9bexABE4WhIO49HPbaoSo=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0711",
|
||||
"place": ".",
|
||||
"place": "mesh",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -119,7 +119,8 @@ what is declared, refusing secrets, taking back what is no longer declared, and
|
||||
|
||||
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
|
||||
|
||||
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `${dir:data}` (`/var/lib/mesh/<module>`): everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `${dir:state}`: the package's own directory, declared for one delivery with the package's owner and mode so the node-engine gives it back (issue 1873); the module keeps nothing there.
|
||||
- `${dir:grants}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `/var/lib/mesh-store`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `${dir:store-data}/dumps`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
// The module's directories (novox/hq issue 1873): its state directory said place ".", which is /var/lib/postgres,
|
||||
// the postgresql package's own (tmpfiles.d/postgresql.conf: postgres, 0700), and its grants directory, placed by
|
||||
// default, lay inside it; the node-engine gave /var/lib/postgres to the operator's account. What the module keeps
|
||||
// moves to the mesh's directory for it, and for one delivery the record "state" states the package's owner and mode,
|
||||
// so the node-engine, which takes a directory it recorded at the same path under the same id as its own, gives it
|
||||
// back. The store's data is not touched: it is /var/lib/mesh-store, as before.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func postgresManifest(t *testing.T) (map[string]map[string]any, map[string]any) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "${dir:state}") {
|
||||
t.Errorf("the manifest still keeps something in ${dir:state}, which is /var/lib/postgres")
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]map[string]any{}
|
||||
list, _ := m["resources"].([]any)
|
||||
for _, x := range list {
|
||||
r, _ := x.(map[string]any)
|
||||
out[r["id"].(string)] = r
|
||||
}
|
||||
return out, m
|
||||
}
|
||||
|
||||
// 1873/AC2: secrets, consumers and grants are kept in <data root>/mesh/postgres; "state" gives /var/lib/postgres back
|
||||
// to postgres, 0700; the store's data and dumps are as they were.
|
||||
func TestTheModuleKeepsItsFilesOutsideThePackagesDirectory(t *testing.T) {
|
||||
rs, m := postgresManifest(t)
|
||||
data := rs["data"]
|
||||
if data == nil || data["type"] != "directory" || data["place"] != "mesh" || data["path"] != nil || data["mode"] != "0700" {
|
||||
t.Fatalf("the module's directory is placed \"mesh\" with no path, 0700 (/var/lib/mesh/postgres): %v", data)
|
||||
}
|
||||
if g := rs["grants"]; g == nil || g["path"] != "${dir:data}/grants" || g["place"] != nil {
|
||||
t.Errorf("grants lies in the module's directory: %v", g)
|
||||
}
|
||||
if c := rs["consumers"]; c == nil || c["path"] != "${dir:data}/consumers.json" {
|
||||
t.Errorf("consumers.json lies in the module's directory: %v", c)
|
||||
}
|
||||
own, _ := m["own-secrets"].(map[string]any)
|
||||
if own["superuser"] != "${dir:data}/superuser.secret" || own["reader"] != "${dir:data}/reader.secret" {
|
||||
t.Errorf("the own secrets lie in the module's directory: %v", own)
|
||||
}
|
||||
state := rs["state"]
|
||||
if state == nil || state["type"] != "directory" || state["path"] != "/var/lib/postgres" || state["owner"] != "postgres" ||
|
||||
state["mode"] != "0700" || state["place"] != nil {
|
||||
t.Errorf("state gives /var/lib/postgres back as postgresql declares it, postgres 0700: %v", state)
|
||||
}
|
||||
|
||||
// The store's data, its dumps and how the container reads them: exactly as before.
|
||||
if s := rs["store-data"]; !reflect.DeepEqual(s, map[string]any{"id": "store-data", "type": "directory",
|
||||
"path": "/var/lib/mesh-store", "mode": "0700", "owner": "999:70", "unknown": "program"}) {
|
||||
t.Errorf("store-data changed: %v", s)
|
||||
}
|
||||
if d := rs["dumps"]; !reflect.DeepEqual(d, map[string]any{"id": "dumps", "type": "directory",
|
||||
"path": "${dir:store-data}/dumps", "mode": "0700", "owner": "999:70", "unknown": "program"}) {
|
||||
t.Errorf("dumps changed: %v", d)
|
||||
}
|
||||
server := rs["server"]
|
||||
if !reflect.DeepEqual(server["volumes"], []any{"/var/lib/mesh-store:/var/lib/postgresql/data",
|
||||
"${dir:data}/superuser.secret:/run/secrets/superuser:ro"}) {
|
||||
t.Errorf("the container's volumes: %v", server["volumes"])
|
||||
}
|
||||
if env, _ := server["env"].(map[string]any); env["PGDATA"] != "/var/lib/postgresql/data/pgdata" {
|
||||
t.Errorf("PGDATA changed: %v", env)
|
||||
}
|
||||
build, _ := m["build"].(map[string]any)
|
||||
artifacts, _ := build["artifacts"].([]any)
|
||||
env := artifacts[0].(map[string]any)["env"].(map[string]any)
|
||||
for key, want := range map[string]string{
|
||||
"MESH_PROVISION_PASSWORD_FILE": "${dir:data}/superuser.secret",
|
||||
"MESH_POSTGRES_READER_PASSWORD_FILE": "${dir:data}/reader.secret",
|
||||
"MESH_CONSUMERS_FILE": "${dir:data}/consumers.json",
|
||||
"MESH_RECEIVES": "${dir:grants}/mesh.json",
|
||||
} {
|
||||
if env[key] != want {
|
||||
t.Errorf("%s is %v, not %s", key, env[key], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,8 +61,8 @@
|
||||
"postgres-database": "${dir:grants}"
|
||||
},
|
||||
"own-secrets": {
|
||||
"superuser": "${dir:state}/superuser.secret",
|
||||
"reader": "${dir:state}/reader.secret"
|
||||
"superuser": "${dir:data}/superuser.secret",
|
||||
"reader": "${dir:data}/reader.secret"
|
||||
},
|
||||
"data": {
|
||||
"own": [
|
||||
@@ -93,22 +93,31 @@
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "state",
|
||||
"id": "data",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": ".",
|
||||
"place": "mesh",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/postgres",
|
||||
"owner": "postgres",
|
||||
"mode": "0700",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "grants",
|
||||
"type": "directory",
|
||||
"path": "${dir:data}/grants",
|
||||
"mode": "0700",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "consumers",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/consumers.json",
|
||||
"path": "${dir:data}/consumers.json",
|
||||
"mode": "0600",
|
||||
"template": "files/consumers.json"
|
||||
},
|
||||
@@ -146,7 +155,7 @@
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/mesh-store:/var/lib/postgresql/data",
|
||||
"${dir:state}/superuser.secret:/run/secrets/superuser:ro"
|
||||
"${dir:data}/superuser.secret:/run/secrets/superuser:ro"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -169,10 +178,10 @@
|
||||
],
|
||||
"env": {
|
||||
"MESH_PROVISION_POSTGRES": "postgres://postgres@127.0.0.1:${port:5432}/postgres?sslmode=disable",
|
||||
"MESH_PROVISION_PASSWORD_FILE": "${dir:state}/superuser.secret",
|
||||
"MESH_PROVISION_PASSWORD_FILE": "${dir:data}/superuser.secret",
|
||||
"MESH_RECEIVES": "${dir:grants}/mesh.json",
|
||||
"MESH_POSTGRES_READER_PASSWORD_FILE": "${dir:state}/reader.secret",
|
||||
"MESH_CONSUMERS_FILE": "${dir:state}/consumers.json"
|
||||
"MESH_POSTGRES_READER_PASSWORD_FILE": "${dir:data}/reader.secret",
|
||||
"MESH_CONSUMERS_FILE": "${dir:data}/consumers.json"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -311,6 +311,8 @@ func TestAnUnreachableHostIsNotRefreshed(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTestSaysHowItAuthenticatedOrWhyNot(t *testing.T) {
|
||||
// The test's own agent, none: the account running it may have one, and the unit level runs with none (issue 1891).
|
||||
t.Setenv("SSH_AUTH_SOCK", "")
|
||||
ok := "debug1: Connecting to ace.internal [10.0.0.2] port 22.\ndebug1: Server accepts key: /h/.ssh/id_ed25519 ED25519 " + edFP + "\nAuthenticated to ace.internal ([10.0.0.2]:22) using \"publickey\".\n"
|
||||
f := &fake{answer: func(c call) forgekey.Ran { return forgekey.Ran{Stderr: ok} }}
|
||||
got, err := client(t.TempDir(), f).Test(context.Background(), "ace")
|
||||
@@ -327,9 +329,15 @@ func TestTestSaysHowItAuthenticatedOrWhyNot(t *testing.T) {
|
||||
if got["ok"] != false || got["why"] != "no key it offered was accepted" || !reflect.DeepEqual(got["offered"], []string{"/h/.ssh/id_box"}) {
|
||||
t.Fatalf("%v", got)
|
||||
}
|
||||
if !strings.Contains(got["note"].(string), "agent") {
|
||||
if note, _ := got["note"].(string); !strings.Contains(note, "agent") {
|
||||
t.Fatalf("no word on the agent: %v", got)
|
||||
}
|
||||
agent := filepath.Join(t.TempDir(), "agent.sock")
|
||||
t.Setenv("SSH_AUTH_SOCK", agent)
|
||||
got, _ = client(t.TempDir(), f).Test(context.Background(), "ace")
|
||||
if got["agent"] != agent || got["note"] != nil {
|
||||
t.Fatalf("the environment's agent is not the one it ran with, or a passphrase note without a passphrase: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckFindsWhatIsWrongAndSaysWhatItDidNotCheck(t *testing.T) {
|
||||
|
||||
@@ -6,4 +6,5 @@ The `systemd` module of the mesh catalogue: installs the package `systemd`; clai
|
||||
|
||||
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
|
||||
|
||||
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `${dir:data}` (`/var/lib/mesh/<module>`): everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `${dir:state}`: the package's own directory, declared for one delivery with the package's owner and mode so the node-engine gives it back (issue 1873); the module keeps nothing there.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
// The module's directories (novox/hq issue 1873): its state directory said place ".", which is /var/lib/systemd,
|
||||
// systemd's own directory (tmpfiles.d/systemd.conf: d /var/lib/systemd 0755 root root); the node-engine gave it to
|
||||
// the operator's account, mode 0700, on every machine. What the tools keep moves to the mesh's directory for the
|
||||
// module, and for one delivery the record "state" states the package's owner and mode, so the node-engine, which
|
||||
// takes a directory it recorded at the same path under the same id as its own, gives it back.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func manifestResources(t *testing.T) (map[string]map[string]any, map[string]any) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]map[string]any{}
|
||||
list, _ := m["resources"].([]any)
|
||||
for _, x := range list {
|
||||
r, _ := x.(map[string]any)
|
||||
out[r["id"].(string)] = r
|
||||
}
|
||||
return out, m
|
||||
}
|
||||
|
||||
// 1873/AC1: the tools' record is kept in <data root>/mesh/systemd; "state" gives /var/lib/systemd back to root, 0755.
|
||||
func TestTheToolsKeepTheirRecordOutsideSystemdsOwnDirectory(t *testing.T) {
|
||||
rs, m := manifestResources(t)
|
||||
data := rs["data"]
|
||||
if data == nil || data["type"] != "directory" || data["place"] != "mesh" || data["path"] != nil {
|
||||
t.Fatalf("the tools' directory is placed \"mesh\" with no path (/var/lib/mesh/systemd): %v", data)
|
||||
}
|
||||
build, _ := m["build"].(map[string]any)
|
||||
artifacts, _ := build["artifacts"].([]any)
|
||||
env := artifacts[0].(map[string]any)["env"].(map[string]any)
|
||||
if env["MESH_SYSTEMD_STATE"] != "${dir:data}" {
|
||||
t.Errorf("MESH_SYSTEMD_STATE is %v, not ${dir:data}", env["MESH_SYSTEMD_STATE"])
|
||||
}
|
||||
state := rs["state"]
|
||||
if state == nil || state["type"] != "directory" || state["path"] != "/var/lib/systemd" || state["owner"] != "root" ||
|
||||
state["mode"] != "0755" || state["place"] != nil {
|
||||
t.Errorf("state gives /var/lib/systemd back as systemd declares it, root 0755: %v", state)
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@
|
||||
"systemd-tools"
|
||||
],
|
||||
"env": {
|
||||
"MESH_SYSTEMD_STATE": "${dir:state}"
|
||||
"MESH_SYSTEMD_STATE": "${dir:data}"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -53,10 +53,18 @@
|
||||
"package": "systemd"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"id": "data",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": ".",
|
||||
"place": "mesh",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/systemd",
|
||||
"owner": "root",
|
||||
"mode": "0755",
|
||||
"unknown": "program"
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user