Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60010aa1fa | ||
|
|
89e15c7bcd | ||
|
|
a21da33d48 | ||
|
|
b53b0e7e2c | ||
|
|
48dd41db91 | ||
|
|
16331cdfdc | ||
|
|
ab3722d3b3 | ||
|
|
66c404fd39 | ||
|
|
3ad9d42be9 | ||
|
|
083515da1b | ||
|
|
ac5d257a1c | ||
|
|
e0bf87c2e3 | ||
|
|
ef1e98f867 | ||
|
|
098c6d3ec7 |
+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=""
|
||||
|
||||
@@ -37,8 +37,17 @@ Three read tools answer what the module stored in its own states, without the gu
|
||||
forge-account provision's ssh name, so the account's own ssh files and git rewrite are used) into a workspace of the
|
||||
work directory, then runs `go mod download` and `go mod verify` in every directory with a `go.mod` that does not vendor,
|
||||
with `GOTOOLCHAIN=local`, `GOFLAGS=-mod=mod` and the module's cache directory. Every command runs with an
|
||||
environment built from scratch (`StepRunner`): `PATH`, `HOME` and what the step passes. Workspaces older than a day
|
||||
are removed.
|
||||
environment built from scratch (`StepRunner`): `PATH`, `HOME` and what the step passes.
|
||||
|
||||
## Room to work and the workspace's end
|
||||
|
||||
Every step is refused, ending `refused` with `refused: <node> has N GiB free under the floor of 20 GiB (on <dir>)`, while
|
||||
the work or the cache directory's filesystem has less than 20 GiB free (build-agent's floor, issue 1839); the health is
|
||||
then unhealthy with the same text, so the builder chooses another node and the controller raises the condition. A
|
||||
workspace is held while a step runs on it. `build_go_release {workspace}` removes it at once (no guard, no floor; one
|
||||
already gone is no error, one a running step holds is refused); its caller calls it once its last step answered. A
|
||||
failed fetch removes its own workspace. A workspace no caller released is removed two hours after its last use (ADR
|
||||
0509), and the removal is said in the log as the caller's fault.
|
||||
|
||||
A pin on a pull request's head (issue 1148): when `go mod download` fails on a `git.novox.be/novox/*` pseudo-version, the
|
||||
fetch looks for that commit among the forge's `refs/pull/*/head`, places the repository at it under the workspace's
|
||||
|
||||
@@ -23,11 +23,6 @@ var commitOK = regexp.MustCompile(`^[0-9a-f]{40}$`)
|
||||
// remote of a fetch is ssh://<it>/<owner>/<repository>.git.
|
||||
const ForgeAlias = "mesh-forge"
|
||||
|
||||
// WorkspaceAge is how long a workspace is kept: a module removes the workspaces older than a day, each time it
|
||||
// fetches and every round it checks its toolchain. A workspace lies in the module's work directory, never in its
|
||||
// home (ADR 0466 §1).
|
||||
const WorkspaceAge = 24 * time.Hour
|
||||
|
||||
var (
|
||||
repositoryOK = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
||||
workspaceOK = regexp.MustCompile(`^[a-z0-9]+$`)
|
||||
@@ -82,20 +77,37 @@ func (m *Module) fetchTool() StepTool {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) {
|
||||
// fetch makes a workspace and fills it. A workspace lies in the module's work directory, never in its home (ADR 0466
|
||||
// §1); it is held while the fetch runs, and removed when the fetch fails, since nobody learns its id to release it
|
||||
// (issue 1839).
|
||||
func (m *Module) fetch(ctx context.Context, args map[string]any) (res any, err error) {
|
||||
// What the builder and the checker ask, in mesh-sdk's one form (ADR 0499 §4): with the module's directory and its
|
||||
// named paths, the workspace is narrowed to them (buildstep.Narrow).
|
||||
req, err := buildstep.ParseFetchRequest(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repository, commit, ref, base := req.Repository, req.Commit, req.Ref, req.Base
|
||||
siblings, err := siblingsArg(args["beside"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.CleanWorkspaces(m.now())
|
||||
id := buildstep.NewID()
|
||||
done := m.spaces().Hold(id)
|
||||
defer func() {
|
||||
done()
|
||||
if err != nil {
|
||||
if rerr := m.spaces().Release(id); rerr != nil && m.Say != nil {
|
||||
m.Say("the failed fetch's workspace %s: %v", id, rerr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return m.fetchInto(ctx, req, id, siblings)
|
||||
}
|
||||
|
||||
func (m *Module) fetchInto(ctx context.Context, req buildstep.FetchRequest, id string, siblings []siblingArg) (any, error) {
|
||||
repository, commit, ref, base := req.Repository, req.Commit, req.Ref, req.Base
|
||||
var err error
|
||||
root := filepath.Join(m.Work, id)
|
||||
tree := filepath.Join(root, "tree")
|
||||
if err := os.MkdirAll(tree, 0o700); err != nil {
|
||||
@@ -236,21 +248,10 @@ func (m *Module) goEnvFor(dir string) []string {
|
||||
return env
|
||||
}
|
||||
|
||||
// CleanWorkspaces removes the workspaces older than WorkspaceAge from the work directory.
|
||||
// CleanWorkspaces removes the workspaces no step holds whose last use is older than buildstep.UnheldBound: the safety
|
||||
// net for a caller that never released one (issue 1839, ADR 0509).
|
||||
func (m *Module) CleanWorkspaces(now time.Time) {
|
||||
entries, err := os.ReadDir(m.Work)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
info, err := e.Info()
|
||||
if err != nil || !e.IsDir() || !workspaceOK.MatchString(e.Name()) || now.Sub(info.ModTime()) < WorkspaceAge {
|
||||
continue
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(m.Work, e.Name())); err != nil && m.Say != nil {
|
||||
m.Say("the workspace %s could not be removed: %v", e.Name(), err)
|
||||
}
|
||||
}
|
||||
m.sweepSaid(m.spaces().Sweep(now, buildstep.UnheldBound))
|
||||
}
|
||||
|
||||
// clock is the time now: Now when a test set it, else the wall clock.
|
||||
|
||||
@@ -265,10 +265,11 @@ func TestARepositoryThatIsNotOwnerSlashNameIsRefusedBeforeAnythingRuns(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
// Workspaces older than a day are removed; younger ones, and anything that is not a workspace, stay.
|
||||
func TestWorkspacesOlderThanADayAreRemoved(t *testing.T) {
|
||||
// Unheld workspaces last used more than two hours ago are removed (issue 1839, ADR 0509); younger ones, and anything
|
||||
// that is not a workspace, stay.
|
||||
func TestUnheldWorkspacesPastTwoHoursAreRemoved(t *testing.T) {
|
||||
m := &Module{Work: t.TempDir()}
|
||||
for name, age := range map[string]time.Duration{"old1": 25 * time.Hour, "young": time.Hour, "Keep-This": 48 * time.Hour} {
|
||||
for name, age := range map[string]time.Duration{"old1": 3 * time.Hour, "young": time.Hour, "Keep-This": 48 * time.Hour} {
|
||||
dir := filepath.Join(m.Work, name)
|
||||
os.MkdirAll(filepath.Join(dir, "tree"), 0o755)
|
||||
when := time.Now().Add(-age)
|
||||
|
||||
@@ -46,6 +46,7 @@ func main() {
|
||||
Work: WorkDir,
|
||||
Cache: CacheDir,
|
||||
}
|
||||
m.Floor = floorOf(m.Node)
|
||||
if u, err := user.Current(); err == nil {
|
||||
m.Home = u.HomeDir
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
@@ -65,6 +66,12 @@ type Module struct {
|
||||
// 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)
|
||||
// Floor refuses every step while the work or cache filesystem has less free than it (issue 1839); main sets
|
||||
// floorOf(node), and a zero Floor checks nothing.
|
||||
Floor buildstep.Floor
|
||||
|
||||
spacesOnce sync.Once
|
||||
workspaces *buildstep.Workspaces
|
||||
}
|
||||
|
||||
// guard is the SDK guard for the version the probe just read: the module names no Go version (ADR 0477 §2), so the
|
||||
@@ -113,6 +120,9 @@ func (m *Module) Health(ctx context.Context) health.Answer {
|
||||
if err := m.checkBubblewrap(ctx); err != nil {
|
||||
return health.Unhealthy(err.Error())
|
||||
}
|
||||
if err := m.room(); err != nil {
|
||||
return health.Unhealthy(err.Error())
|
||||
}
|
||||
return health.Healthy(fmt.Sprintf("%s installed, the version the module builds with (recipe %s)", installed, m.Recipe.Version()))
|
||||
}
|
||||
|
||||
@@ -125,6 +135,10 @@ func (m *Module) Long(tool string, run func(ctx context.Context, args map[string
|
||||
if err := m.Check(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.room(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer m.holdArg(args)()
|
||||
return run(ctx, args)
|
||||
})
|
||||
if err != nil {
|
||||
@@ -141,6 +155,10 @@ func (m *Module) Short(run func(ctx context.Context, args map[string]any) (any,
|
||||
if err := m.Check(m.Ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.room(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer m.holdArg(args)()
|
||||
return run(m.Ctx, args)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ func TestAGoThatCannotBeRunRefusesEveryStepAndHealthIsUnhealthy(t *testing.T) {
|
||||
{Name: "short_probe", Run: run},
|
||||
}, m.StepTools()...)
|
||||
for _, tool := range m.Tools(steps) {
|
||||
if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == ToolchainsReadTool {
|
||||
if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == ToolchainsReadTool || tool.Name == ReleaseTool {
|
||||
continue
|
||||
}
|
||||
var reason string
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
// A step's room to work and its workspace's end (novox/hq issue 1839, ADR 0509): every step is refused while the work
|
||||
// or cache filesystem is under the floor, a workspace is held while a step runs on it, removed when its caller releases
|
||||
// it, and swept unheld past buildstep.UnheldBound.
|
||||
|
||||
import (
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
)
|
||||
|
||||
// ReleaseTool removes a workspace whose caller's steps have ended.
|
||||
const ReleaseTool = "build_go_release"
|
||||
|
||||
// floorOf is the floor the module keeps on node: build-agent's.
|
||||
func floorOf(node string) buildstep.Floor {
|
||||
return buildstep.Floor{Node: node, Bytes: buildstep.FreeSpaceFloor}
|
||||
}
|
||||
|
||||
// room refuses while the work or cache filesystem is under the floor.
|
||||
func (m *Module) room() error { return m.Floor.Check(m.Work, m.Cache) }
|
||||
|
||||
// spaces are the module's workspaces under Work.
|
||||
func (m *Module) spaces() *buildstep.Workspaces {
|
||||
m.spacesOnce.Do(func() { m.workspaces = &buildstep.Workspaces{Dir: m.Work} })
|
||||
return m.workspaces
|
||||
}
|
||||
|
||||
// holdArg holds the workspace a step's arguments name while the step runs; nothing when they name none.
|
||||
func (m *Module) holdArg(args map[string]any) func() {
|
||||
ws, _ := args["workspace"].(string)
|
||||
if ws == "" {
|
||||
return func() {}
|
||||
}
|
||||
return m.spaces().Hold(ws)
|
||||
}
|
||||
|
||||
// releaseTool is build_go_release: no guard and no floor, since it only frees room.
|
||||
func (m *Module) releaseTool() stdio.Tool {
|
||||
return stdio.Tool{Name: ReleaseTool,
|
||||
Description: "Remove a workspace whose steps have ended (fetch, test, build): its caller says so once its last step " +
|
||||
"answered. A workspace already gone is no error; one a running step holds is refused. A workspace never released " +
|
||||
"is removed two hours after its last use.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"workspace": map[string]any{"type": "string", "description": "the workspace id a fetch answered"},
|
||||
}, "required": []string{"workspace"}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
ws, _ := args["workspace"].(string)
|
||||
if err := m.spaces().Release(ws); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"released": ws}, nil
|
||||
}}
|
||||
}
|
||||
|
||||
// sweepSaid is CleanWorkspaces' saying of a workspace a caller never released: a fault of the caller (ADR 0509).
|
||||
func (m *Module) sweepSaid(removed []string) {
|
||||
if len(removed) > 0 && m.Say != nil {
|
||||
m.Say("removed %d workspaces no caller released within %s: %v", len(removed), buildstep.UnheldBound, removed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
const underTheFloor = "refused: ace has 4 GiB free under the floor of 20 GiB"
|
||||
|
||||
func underFloor(node string) buildstep.Floor {
|
||||
f := floorOf(node)
|
||||
f.Free = func(string) (uint64, error) { return 4 << 30, nil }
|
||||
return f
|
||||
}
|
||||
|
||||
// 1839/AC4: the module as main makes it keeps build-agent's floor of 20 GiB.
|
||||
func TestTheModuleKeepsTheFloorOf20GiB(t *testing.T) {
|
||||
if f := floorOf("ace"); f.Bytes != 20<<30 || f.Node != "ace" {
|
||||
t.Errorf("floorOf = %+v, want ace and 20 GiB", f)
|
||||
}
|
||||
}
|
||||
|
||||
// 1839/AC1: under the floor every step tool is refused, naming the node, what is free and the floor, no step runs, and
|
||||
// the module is unhealthy saying so, so the builder chooses another node.
|
||||
func TestUnderTheFloorEveryStepIsRefusedAndTheModuleIsUnhealthy(t *testing.T) {
|
||||
states := newStates()
|
||||
m := moduleWith(states, "go1.27.1")
|
||||
m.Work, m.Cache, m.Floor = t.TempDir(), t.TempDir(), underFloor("ace")
|
||||
ran := false
|
||||
run := func(context.Context, map[string]any) (any, error) { ran = true; return "done", nil }
|
||||
steps := []StepTool{{Name: "long_probe", Long: true, Run: run}, {Name: "short_probe", Run: run}}
|
||||
for _, tool := range m.Tools(steps) {
|
||||
switch tool.Name {
|
||||
case "long_probe":
|
||||
if _, err := tool.Run(map[string]any{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st := states.waitEnd(t); st.State != delivery.StepRefused || !strings.HasPrefix(st.Reason, underTheFloor) {
|
||||
t.Errorf("the long step ended %s: %q, want refused: %q", st.State, st.Reason, underTheFloor)
|
||||
}
|
||||
case "short_probe":
|
||||
if _, err := tool.Run(map[string]any{}); err == nil || !strings.HasPrefix(err.Error(), underTheFloor) {
|
||||
t.Errorf("the short step answered %v, want %q", err, underTheFloor)
|
||||
}
|
||||
}
|
||||
}
|
||||
if ran {
|
||||
t.Errorf("a step ran under the floor")
|
||||
}
|
||||
if a := m.Health(context.Background()); a.State != health.StateUnhealthy || !strings.HasPrefix(a.Why, underTheFloor) {
|
||||
t.Errorf("health = %+v, want unhealthy: %q", a, underTheFloor)
|
||||
}
|
||||
}
|
||||
|
||||
func releaseTool(t *testing.T, m *Module) func(map[string]any) (any, error) {
|
||||
t.Helper()
|
||||
for _, tool := range m.Tools(m.StepTools()) {
|
||||
if tool.Name == ReleaseTool {
|
||||
return tool.Run
|
||||
}
|
||||
}
|
||||
t.Fatalf("no tool %s", ReleaseTool)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 1839/AC2: build_go_release removes the workspace at once, even under the floor; a second release is no error.
|
||||
func TestReleaseRemovesTheWorkspaceAtOnce(t *testing.T) {
|
||||
m, _ := fetchModule(t, &fakeStep{})
|
||||
m.Floor = underFloor("ace")
|
||||
ws := filepath.Join(m.Work, "abc1")
|
||||
os.MkdirAll(filepath.Join(ws, "tree"), 0o700)
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, err := releaseTool(t, m)(map[string]any{"workspace": "abc1"}); err != nil {
|
||||
t.Fatalf("release %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(ws); !os.IsNotExist(err) {
|
||||
t.Errorf("the workspace is still there after its release")
|
||||
}
|
||||
}
|
||||
|
||||
// 1839/AC2: a fetch that fails leaves no workspace behind: nobody was told its id, so nobody could release it.
|
||||
func TestAFailedFetchLeavesNoWorkspace(t *testing.T) {
|
||||
f := &fakeStep{gomods: "go.mod\n", goRule: func(string) forgekey.Ran { return forgekey.Ran{Status: 1, Stderr: "boom"} }}
|
||||
m, states := fetchModule(t, f)
|
||||
if _, err := fetchTool(t, m)(fetchArgs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st := states.waitEnd(t); st.State != delivery.StepFailed {
|
||||
t.Fatalf("the step ended %s, want failed", st.State)
|
||||
}
|
||||
if entries, _ := os.ReadDir(m.Work); len(entries) != 0 {
|
||||
t.Errorf("the failed fetch left %d entries in the work directory", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
// 1839/AC3: a workspace a running step holds is never swept, however old; an unheld one past the two-hour bound is.
|
||||
func TestTheSweepKeepsAHeldWorkspaceAndRemovesAnUnheldOnePastTwoHours(t *testing.T) {
|
||||
m := &Module{Work: t.TempDir()}
|
||||
for _, name := range []string{"held1", "lost1"} {
|
||||
dir := filepath.Join(m.Work, name)
|
||||
os.MkdirAll(filepath.Join(dir, "tree"), 0o755)
|
||||
}
|
||||
done := m.spaces().Hold("held1")
|
||||
defer done()
|
||||
when := time.Now().Add(-3 * time.Hour)
|
||||
for _, name := range []string{"held1", "lost1"} {
|
||||
os.Chtimes(filepath.Join(m.Work, name), when, when)
|
||||
}
|
||||
m.CleanWorkspaces(time.Now())
|
||||
if _, err := os.Stat(filepath.Join(m.Work, "held1")); err != nil {
|
||||
t.Errorf("the held workspace was swept")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(m.Work, "lost1")); !os.IsNotExist(err) {
|
||||
t.Errorf("the unheld workspace three hours old was kept")
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ func (m *Module) Tools(steps []StepTool) []stdio.Tool {
|
||||
tools = append(tools, t)
|
||||
}
|
||||
tools = append(tools, m.ReadTools()...)
|
||||
tools = append(tools, m.releaseTool())
|
||||
return append(tools,
|
||||
stdio.Tool{Name: StatusTool,
|
||||
Description: "What the Go build module is on this machine: the Go version its recipe builds with and the one installed, " +
|
||||
|
||||
@@ -3,7 +3,7 @@ module meshbuildgo
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023062828-6147a2045467
|
||||
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,7 @@
|
||||
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=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023062828-6147a2045467 h1:i85+fFT0wZiTZ9CRVfMzWg1+Zr7oOLl3UH7hEaKcOIc=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023062828-6147a2045467/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=
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"build_go_test",
|
||||
"build_go_digest",
|
||||
"build_go_build",
|
||||
"build_go_release",
|
||||
"build_go_step",
|
||||
"build_go_steps",
|
||||
"build_go_toolchains",
|
||||
|
||||
@@ -37,6 +37,12 @@ The Dockerfile's steps may use the network (ADR 0487 §6); nothing a step can re
|
||||
- It refuses, naming why, an image whose build context is a whole repository (`context`) or whose `build.on` names
|
||||
another module's artifact (`unsupportedImage` in `build.go`): those forms belong to the images build-agent builds
|
||||
until it is removed at R4g.
|
||||
- `build_image_release {workspace}` removes a workspace at once (no guard, no floor; one already gone is no error, one a
|
||||
running step holds is refused); its caller calls it once its last step answered. A failed fetch removes its own
|
||||
workspace, and one no caller released is removed two hours after its last use (ADR 0509).
|
||||
- A fetch and a build are refused, `refused: <node> has N GiB free under the floor of 20 GiB (on <dir>)`, and the health
|
||||
is unhealthy with that text, while the work directory's or the image store's filesystem has less than 20 GiB free
|
||||
(issue 1839). A copy writes nothing to the disk and is not refused.
|
||||
- `build_image_step`, `build_image_steps`, `build_image_status` and `build_image_health` read.
|
||||
|
||||
The registry's address is placed into `<home>/registry` as `${seat:mesh-artifact-store:reach}` (host:port): the provisioner's client fills a moved module's placement, and the artifact store has not moved, so a `${bound:artifact-store:...}` is refused at composition (issue 1703).
|
||||
|
||||
@@ -26,11 +26,6 @@ var (
|
||||
// remote of a fetch is ssh://<it>/<owner>/<repository>.git.
|
||||
const ForgeAlias = "mesh-forge"
|
||||
|
||||
// WorkspaceAge is how long a workspace is kept: a module removes the workspaces older than a day, each time it
|
||||
// fetches and every round it checks itself. A workspace lies in the module's work directory, never in its home
|
||||
// (ADR 0466 §1).
|
||||
const WorkspaceAge = 24 * time.Hour
|
||||
|
||||
// FetchResult is what a passed fetch step says: the workspace it made and the commit in it, and, only when the call
|
||||
// gave a base, the merge preview of ADR 0484 clause 3 (the trunk commit, the merge commit of the head onto it, the
|
||||
// files in conflict, the paths the merge changes and the tree of each module directory). A head that does not
|
||||
@@ -66,7 +61,9 @@ func (m *Module) fetchTool() StepTool {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) {
|
||||
// fetch makes a workspace and fills it. The workspace is held while the fetch runs, and removed when the fetch fails,
|
||||
// since nobody learns its id to release it (issue 1839).
|
||||
func (m *Module) fetch(ctx context.Context, args map[string]any) (res any, err error) {
|
||||
repository, _ := args["repository"].(string)
|
||||
commit, _ := args["commit"].(string)
|
||||
ref, _ := args["ref"].(string)
|
||||
@@ -82,6 +79,19 @@ func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) {
|
||||
}
|
||||
m.CleanWorkspaces(m.clock())
|
||||
id := buildstep.NewID()
|
||||
done := m.spaces().Hold(id)
|
||||
defer func() {
|
||||
done()
|
||||
if err != nil {
|
||||
if rerr := m.spaces().Release(id); rerr != nil && m.Say != nil {
|
||||
m.Say("the failed fetch's workspace %s: %v", id, rerr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return m.fetchInto(ctx, repository, commit, ref, base, id)
|
||||
}
|
||||
|
||||
func (m *Module) fetchInto(ctx context.Context, repository, commit, ref, base, id string) (any, error) {
|
||||
tree := filepath.Join(m.Work, id, "tree")
|
||||
if err := os.MkdirAll(tree, 0o700); err != nil {
|
||||
return nil, err
|
||||
@@ -124,21 +134,10 @@ func (m *Module) merge(ctx context.Context, res *FetchResult, tree, remote, base
|
||||
return err
|
||||
}
|
||||
|
||||
// CleanWorkspaces removes the workspaces older than WorkspaceAge from the work directory.
|
||||
// CleanWorkspaces removes the workspaces no step holds whose last use is older than buildstep.UnheldBound: the safety
|
||||
// net for a caller that never released one (issue 1839, ADR 0509).
|
||||
func (m *Module) CleanWorkspaces(now time.Time) {
|
||||
entries, err := os.ReadDir(m.Work)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
info, err := e.Info()
|
||||
if err != nil || !e.IsDir() || !workspaceOK.MatchString(e.Name()) || now.Sub(info.ModTime()) < WorkspaceAge {
|
||||
continue
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(m.Work, e.Name())); err != nil && m.Say != nil {
|
||||
m.Say("the workspace %s could not be removed: %v", e.Name(), err)
|
||||
}
|
||||
}
|
||||
m.sweepSaid(m.spaces().Sweep(now, buildstep.UnheldBound))
|
||||
}
|
||||
|
||||
// clock is the time now: Now when a test set it, else the wall clock.
|
||||
|
||||
@@ -37,6 +37,7 @@ func main() {
|
||||
Work: WorkDir,
|
||||
Store: StoreDir,
|
||||
}
|
||||
m.Floor = floorOf(m.Node)
|
||||
if u, err := user.Current(); err == nil {
|
||||
m.Home, m.Account, m.UID = u.HomeDir, u.Username, u.Uid
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
@@ -47,6 +46,12 @@ type Module struct {
|
||||
RegistryPause time.Duration
|
||||
// Now is the clock; time.Now when nil.
|
||||
Now func() time.Time
|
||||
// Floor refuses every fetch and build while the work or the image store's filesystem has less free than it (issue
|
||||
// 1839); main sets floorOf(node), and a zero Floor checks nothing.
|
||||
Floor buildstep.Floor
|
||||
|
||||
spacesOnce sync.Once
|
||||
workspaces *buildstep.Workspaces
|
||||
}
|
||||
|
||||
// Look is what the module found out about its machine.
|
||||
@@ -60,6 +65,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 +77,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}
|
||||
}
|
||||
@@ -106,6 +111,9 @@ func (m *Module) Health(ctx context.Context) health.Answer {
|
||||
if err != nil {
|
||||
return health.Unhealthy(err.Error())
|
||||
}
|
||||
if err := m.room(); err != nil {
|
||||
return health.Unhealthy(err.Error())
|
||||
}
|
||||
return health.Healthy(fmt.Sprintf("buildah %s installed; %s holds %d subordinate uids and %d gids; a user namespace can be made", l.Buildah, l.Account, l.UIDs, l.GIDs))
|
||||
}
|
||||
|
||||
@@ -128,6 +136,10 @@ func (m *Module) Long(tool string, run func(ctx context.Context, args map[string
|
||||
if err := m.Check(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.room(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer m.holdArg(args)()
|
||||
return run(ctx, args)
|
||||
})
|
||||
if err != nil {
|
||||
@@ -144,6 +156,10 @@ func (m *Module) Short(run func(ctx context.Context, args map[string]any) (any,
|
||||
if err := m.Check(m.Ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.room(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer m.holdArg(args)()
|
||||
return run(m.Ctx, args)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,61 @@
|
||||
package main
|
||||
|
||||
// A step's room to work and its workspace's end (novox/hq issue 1839, ADR 0509): every fetch and build is refused while
|
||||
// the work or image store's filesystem is under the floor, a workspace is held while a step runs on it, removed when its caller releases
|
||||
// it, and swept unheld past buildstep.UnheldBound.
|
||||
|
||||
import (
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
)
|
||||
|
||||
// ReleaseTool removes a workspace whose caller's steps have ended.
|
||||
const ReleaseTool = "build_image_release"
|
||||
|
||||
// floorOf is the floor the module keeps on node: build-agent's.
|
||||
func floorOf(node string) buildstep.Floor {
|
||||
return buildstep.Floor{Node: node, Bytes: buildstep.FreeSpaceFloor}
|
||||
}
|
||||
|
||||
// room refuses while the work or the image store's filesystem is under the floor.
|
||||
func (m *Module) room() error { return m.Floor.Check(m.Work, m.Store) }
|
||||
|
||||
// spaces are the module's workspaces under Work.
|
||||
func (m *Module) spaces() *buildstep.Workspaces {
|
||||
m.spacesOnce.Do(func() { m.workspaces = &buildstep.Workspaces{Dir: m.Work} })
|
||||
return m.workspaces
|
||||
}
|
||||
|
||||
// holdArg holds the workspace a step's arguments name while the step runs; nothing when they name none.
|
||||
func (m *Module) holdArg(args map[string]any) func() {
|
||||
ws, _ := args["workspace"].(string)
|
||||
if ws == "" {
|
||||
return func() {}
|
||||
}
|
||||
return m.spaces().Hold(ws)
|
||||
}
|
||||
|
||||
// releaseTool is build_image_release: no guard and no floor, since it only frees room.
|
||||
func (m *Module) releaseTool() stdio.Tool {
|
||||
return stdio.Tool{Name: ReleaseTool,
|
||||
Description: "Remove a workspace whose steps have ended (fetch, build): its caller says so once its last step " +
|
||||
"answered. A workspace already gone is no error; one a running step holds is refused. A workspace never released " +
|
||||
"is removed two hours after its last use.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"workspace": map[string]any{"type": "string", "description": "the workspace id a fetch answered"},
|
||||
}, "required": []string{"workspace"}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
ws, _ := args["workspace"].(string)
|
||||
if err := m.spaces().Release(ws); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"released": ws}, nil
|
||||
}}
|
||||
}
|
||||
|
||||
// sweepSaid is CleanWorkspaces' saying of a workspace a caller never released: a fault of the caller (ADR 0509).
|
||||
func (m *Module) sweepSaid(removed []string) {
|
||||
if len(removed) > 0 && m.Say != nil {
|
||||
m.Say("removed %d workspaces no caller released within %s: %v", len(removed), buildstep.UnheldBound, removed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
const underTheFloor = "refused: ace has 4 GiB free under the floor of 20 GiB"
|
||||
|
||||
func underFloor(node string) buildstep.Floor {
|
||||
f := floorOf(node)
|
||||
f.Free = func(string) (uint64, error) { return 4 << 30, nil }
|
||||
return f
|
||||
}
|
||||
|
||||
// 1839/AC4: the module as main makes it keeps build-agent's floor of 20 GiB.
|
||||
func TestTheModuleKeepsTheFloorOf20GiB(t *testing.T) {
|
||||
if f := floorOf("ace"); f.Bytes != 20<<30 || f.Node != "ace" {
|
||||
t.Errorf("floorOf = %+v, want ace and 20 GiB", f)
|
||||
}
|
||||
}
|
||||
|
||||
// 1839/AC1: under the floor a fetch and a build are refused, naming the node, what is free and the floor, nothing runs,
|
||||
// and the module is unhealthy saying so, so the builder chooses another node.
|
||||
func TestUnderTheFloorAFetchAndABuildAreRefusedAndTheModuleIsUnhealthy(t *testing.T) {
|
||||
f := newFake(t)
|
||||
states := newStates()
|
||||
m := moduleWith(t, f, states)
|
||||
m.Floor = underFloor("ace")
|
||||
for name, args := range map[string]map[string]any{
|
||||
FetchTool: {"repository": "novox/mesh-catalog", "commit": commitSHA},
|
||||
BuildTool: {"workspace": "abc1", "module": "modules/m", "artifact": "image"},
|
||||
} {
|
||||
if _, err := tool(t, m, name)(args); err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
if st := states.waitEnd(t); st.State != delivery.StepRefused || !strings.HasPrefix(st.Reason, underTheFloor) {
|
||||
t.Errorf("%s ended %s: %q, want refused: %q", name, st.State, st.Reason, underTheFloor)
|
||||
}
|
||||
}
|
||||
for _, l := range f.lines() {
|
||||
if strings.HasPrefix(l, "git ") || strings.Contains(l, " bud ") {
|
||||
t.Errorf("a refused step ran %q", l)
|
||||
}
|
||||
}
|
||||
if a := m.Health(context.Background()); a.State != health.StateUnhealthy || !strings.HasPrefix(a.Why, underTheFloor) {
|
||||
t.Errorf("health = %+v, want unhealthy: %q", a, underTheFloor)
|
||||
}
|
||||
}
|
||||
|
||||
// 1839/AC2: build_image_release removes the workspace at once, even under the floor; a second release is no error.
|
||||
func TestReleaseRemovesTheWorkspaceAtOnce(t *testing.T) {
|
||||
m := moduleWith(t, newFake(t), newStates())
|
||||
m.Floor = underFloor("ace")
|
||||
ws := filepath.Join(m.Work, "abc1")
|
||||
os.MkdirAll(filepath.Join(ws, "tree"), 0o700)
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, err := tool(t, m, ReleaseTool)(map[string]any{"workspace": "abc1"}); err != nil {
|
||||
t.Fatalf("release %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(ws); !os.IsNotExist(err) {
|
||||
t.Errorf("the workspace is still there after its release")
|
||||
}
|
||||
}
|
||||
|
||||
// 1839/AC2: a fetch that fails leaves no workspace behind: nobody was told its id, so nobody could release it.
|
||||
func TestAFailedFetchLeavesNoWorkspace(t *testing.T) {
|
||||
f := newFake(t)
|
||||
states := newStates()
|
||||
m := moduleWith(t, f, states)
|
||||
m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
||||
if name == "git" && strings.Contains(strings.Join(args, " "), " fetch ") {
|
||||
return forgekey.Ran{Status: 128, Stderr: "fatal: unreachable"}
|
||||
}
|
||||
return f.run(ctx, env, name, args...)
|
||||
}
|
||||
if _, err := tool(t, m, FetchTool)(map[string]any{"repository": "novox/mesh-catalog", "commit": commitSHA}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st := states.waitEnd(t); st.State != delivery.StepFailed {
|
||||
t.Fatalf("the step ended %s (%s), want failed", st.State, st.Reason)
|
||||
}
|
||||
if entries, _ := os.ReadDir(m.Work); len(entries) != 0 {
|
||||
t.Errorf("the failed fetch left %d entries in the work directory", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
// 1839/AC3: a workspace a running step holds is never swept, however old; an unheld one past the two-hour bound is.
|
||||
func TestTheSweepKeepsAHeldWorkspaceAndRemovesAnUnheldOnePastTwoHours(t *testing.T) {
|
||||
m := &Module{Work: t.TempDir()}
|
||||
for _, name := range []string{"held1", "lost1"} {
|
||||
os.MkdirAll(filepath.Join(m.Work, name, "tree"), 0o755)
|
||||
}
|
||||
done := m.spaces().Hold("held1")
|
||||
defer done()
|
||||
when := time.Now().Add(-3 * time.Hour)
|
||||
for _, name := range []string{"held1", "lost1"} {
|
||||
os.Chtimes(filepath.Join(m.Work, name), when, when)
|
||||
}
|
||||
m.CleanWorkspaces(time.Now())
|
||||
if _, err := os.Stat(filepath.Join(m.Work, "held1")); err != nil {
|
||||
t.Errorf("the held workspace was swept")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(m.Work, "lost1")); !os.IsNotExist(err) {
|
||||
t.Errorf("the unheld workspace three hours old was kept")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ func (m *Module) Tools(steps []StepTool) []stdio.Tool {
|
||||
tools = append(tools, t)
|
||||
}
|
||||
tools = append(tools, m.ReadTools()...)
|
||||
tools = append(tools, m.releaseTool())
|
||||
return append(tools,
|
||||
stdio.Tool{Name: StatusTool,
|
||||
Description: "What the image build module is on this machine: the buildah installed, the account's subordinate " +
|
||||
|
||||
@@ -3,7 +3,7 @@ module meshbuildimage
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023062828-6147a2045467
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023062828-6147a2045467 h1:i85+fFT0wZiTZ9CRVfMzWg1+Zr7oOLl3UH7hEaKcOIc=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023062828-6147a2045467/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=
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"build_image_fetch",
|
||||
"build_image_build",
|
||||
"build_image_copy",
|
||||
"build_image_release",
|
||||
"build_image_step",
|
||||
"build_image_steps",
|
||||
"build_image_status",
|
||||
|
||||
@@ -57,7 +57,11 @@ toolchain install: a long step answers at once with its id and ends on `<node>.<
|
||||
removed). The mesh's registry reaches `npm ci` through a private config file (`--userconfig`), so
|
||||
other `@novox/` packages install. A module with packages and no lock does not fail the fetch: its result lists it
|
||||
under `refused` with the reason, and a build of that bundle fails naming it (issue 1459). Every command runs through `forgekey.ExecRunnerClean(0)` with PATH, HOME, the locale and its own
|
||||
words, nothing of this process's environment. Workspaces older than a day are removed at each fetch.
|
||||
words, nothing of this process's environment. A failed fetch removes its own workspace; `build_node_release {workspace}` removes one at once (no guard, no
|
||||
floor; its caller calls it once its last step answered), and one no caller released is removed two hours after its
|
||||
last use (ADR 0509), never while a step holds it. Every step is refused, `refused: <node> has N GiB free under the
|
||||
floor of 20 GiB (on <dir>)`, and the health is unhealthy with that text, while the work or the toolchain cache
|
||||
filesystem has less than 20 GiB free (issue 1839).
|
||||
The arguments are mesh-sdk's `buildstep.FetchRequest` (ADR 0499 §4): with `dir` and `paths` (from the builder, never
|
||||
from a manifest) the workspace is narrowed to those paths, the manifest and the lock files before anything else. A
|
||||
member is built against the SDK version its own lock names (the lead's decision L10 of R4e, ADR 0500 §5-§6): the
|
||||
|
||||
@@ -33,9 +33,6 @@ const ForgeAlias = "mesh-forge"
|
||||
// sdkPackage is the package the toolchain carries and a module's own dependencies never supply.
|
||||
const sdkPackage = "@novox/mesh-sdk"
|
||||
|
||||
// WorkspaceAge is how long a workspace is kept.
|
||||
const WorkspaceAge = 24 * time.Hour
|
||||
|
||||
var commitOK = regexp.MustCompile(`^[0-9a-f]{40}$`)
|
||||
|
||||
var repositoryOK = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
||||
@@ -105,20 +102,36 @@ func (m *Module) fetchTool() StepTool {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) {
|
||||
// fetch makes a workspace and fills it. The workspace is held while the fetch runs, and removed when the fetch fails,
|
||||
// since nobody learns its id to release it (issue 1839).
|
||||
func (m *Module) fetch(ctx context.Context, args map[string]any) (res any, err error) {
|
||||
// What the builder and the checker ask, in mesh-sdk's one form (ADR 0499 §4): with the module's directory and its
|
||||
// named paths, the workspace is narrowed to them (buildstep.Narrow).
|
||||
req, err := buildstep.ParseFetchRequest(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repository, commit, ref, base := req.Repository, req.Commit, req.Ref, req.Base
|
||||
group, err := groupPackageArg(args["package"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.CleanWorkspaces(m.now())
|
||||
id := buildstep.NewID()
|
||||
done := m.spaces().Hold(id)
|
||||
defer func() {
|
||||
done()
|
||||
if err != nil {
|
||||
if rerr := m.spaces().Release(id); rerr != nil && m.Say != nil {
|
||||
m.Say("the failed fetch's workspace %s: %v", id, rerr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return m.fetchInto(ctx, req, group, id)
|
||||
}
|
||||
|
||||
func (m *Module) fetchInto(ctx context.Context, req buildstep.FetchRequest, group *groupPackage, id string) (any, error) {
|
||||
repository, commit, ref, base := req.Repository, req.Commit, req.Ref, req.Base
|
||||
var err error
|
||||
root := filepath.Join(m.Work, id)
|
||||
tree := filepath.Join(root, "tree")
|
||||
if err := os.MkdirAll(tree, 0o700); err != nil {
|
||||
@@ -354,19 +367,8 @@ func removeSDKCopies(nm string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanWorkspaces removes the workspaces older than WorkspaceAge from the work directory.
|
||||
// CleanWorkspaces removes the workspaces no step holds whose last use is older than buildstep.UnheldBound: the safety
|
||||
// net for a caller that never released one (issue 1839, ADR 0509).
|
||||
func (m *Module) CleanWorkspaces(now time.Time) {
|
||||
entries, err := os.ReadDir(m.Work)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
info, err := e.Info()
|
||||
if err != nil || !e.IsDir() || !workspaceOK.MatchString(e.Name()) || now.Sub(info.ModTime()) < WorkspaceAge {
|
||||
continue
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(m.Work, e.Name())); err != nil && m.Say != nil {
|
||||
m.Say("the workspace %s could not be removed: %v", e.Name(), err)
|
||||
}
|
||||
}
|
||||
m.sweepSaid(m.spaces().Sweep(now, buildstep.UnheldBound))
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ func main() {
|
||||
Bubblewrap: ProbeBubblewrap,
|
||||
RegistryFile: filepath.Join(home, "registry"),
|
||||
}
|
||||
m.Floor = floorOf(m.Node)
|
||||
// Outside a launch by the runtime — a build, a check — there is no node and no state to write.
|
||||
if m.Node != "" {
|
||||
go m.Start(ctx)
|
||||
|
||||
@@ -59,8 +59,14 @@ type Module struct {
|
||||
// unhealthy, while it is missing or older than BubblewrapLeast. Nil probes nothing.
|
||||
Bubblewrap func(ctx context.Context) (string, error)
|
||||
|
||||
mu sync.Mutex // guards Recipe
|
||||
install sync.Mutex // one install at a time
|
||||
// Floor refuses every step while the work or toolchain cache filesystem has less free than it (issue 1839); main
|
||||
// sets floorOf(node), and a zero Floor checks nothing.
|
||||
Floor buildstep.Floor
|
||||
|
||||
spacesOnce sync.Once
|
||||
workspaces *buildstep.Workspaces
|
||||
mu sync.Mutex // guards Recipe
|
||||
install sync.Mutex // one install at a time
|
||||
}
|
||||
|
||||
func (m *Module) recipe() Recipe {
|
||||
@@ -122,6 +128,7 @@ func (m *Module) Start(ctx context.Context) {
|
||||
if err := m.Prepare(ctx); err != nil && m.Say != nil {
|
||||
m.Say("the toolchain: %v", err)
|
||||
}
|
||||
m.CleanWorkspaces(m.now())
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
@@ -138,6 +145,9 @@ func (m *Module) Health(ctx context.Context) health.Answer {
|
||||
if err != nil {
|
||||
return health.Unhealthy(err.Error())
|
||||
}
|
||||
if err := m.room(); err != nil {
|
||||
return health.Unhealthy(err.Error())
|
||||
}
|
||||
return health.Healthy(fmt.Sprintf("node %s installed, the version the module builds with (recipe %s)", installed, m.recipe().Version()))
|
||||
}
|
||||
|
||||
@@ -150,6 +160,10 @@ func (m *Module) Long(tool string, run func(ctx context.Context, args map[string
|
||||
if err := m.Prepare(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.room(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer m.holdArg(args)()
|
||||
return run(ctx, args)
|
||||
})
|
||||
if err != nil {
|
||||
@@ -166,6 +180,10 @@ func (m *Module) Short(run func(ctx context.Context, args map[string]any) (any,
|
||||
if err := m.Prepare(m.Ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.room(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer m.holdArg(args)()
|
||||
return run(m.Ctx, args)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ func TestANodeThatCannotBeRunRefusesEveryStepAndHealthIsUnhealthy(t *testing.T)
|
||||
{Name: "short_probe", Run: run},
|
||||
}, (&Module{}).StepTools()...)
|
||||
for _, tool := range m.Tools(steps) {
|
||||
if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == ToolchainsReadTool {
|
||||
if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == ToolchainsReadTool || tool.Name == ReleaseTool {
|
||||
continue
|
||||
}
|
||||
var reason string
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package main
|
||||
|
||||
// A step's room to work and its workspace's end (novox/hq issue 1839, ADR 0509): every step is refused while the work
|
||||
// or toolchain cache filesystem is under the floor, a workspace is held while a step runs on it, removed when its caller releases
|
||||
// it, and swept unheld past buildstep.UnheldBound.
|
||||
|
||||
import (
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
)
|
||||
|
||||
// ReleaseTool removes a workspace whose caller's steps have ended.
|
||||
const ReleaseTool = "build_node_release"
|
||||
|
||||
// floorOf is the floor the module keeps on node: build-agent's.
|
||||
func floorOf(node string) buildstep.Floor {
|
||||
return buildstep.Floor{Node: node, Bytes: buildstep.FreeSpaceFloor}
|
||||
}
|
||||
|
||||
// room refuses while the work or toolchain cache filesystem is under the floor.
|
||||
func (m *Module) room() error {
|
||||
paths := []string{m.Work}
|
||||
if m.Toolchain != nil {
|
||||
paths = append(paths, m.Toolchain.Paths.Cache)
|
||||
}
|
||||
return m.Floor.Check(paths...)
|
||||
}
|
||||
|
||||
// spaces are the module's workspaces under Work.
|
||||
func (m *Module) spaces() *buildstep.Workspaces {
|
||||
m.spacesOnce.Do(func() { m.workspaces = &buildstep.Workspaces{Dir: m.Work} })
|
||||
return m.workspaces
|
||||
}
|
||||
|
||||
// holdArg holds the workspace a step's arguments name while the step runs; nothing when they name none.
|
||||
func (m *Module) holdArg(args map[string]any) func() {
|
||||
ws, _ := args["workspace"].(string)
|
||||
if ws == "" {
|
||||
return func() {}
|
||||
}
|
||||
return m.spaces().Hold(ws)
|
||||
}
|
||||
|
||||
// releaseTool is build_node_release: no guard and no floor, since it only frees room.
|
||||
func (m *Module) releaseTool() stdio.Tool {
|
||||
return stdio.Tool{Name: ReleaseTool,
|
||||
Description: "Remove a workspace whose steps have ended (fetch, test, build): its caller says so once its last step " +
|
||||
"answered. A workspace already gone is no error; one a running step holds is refused. A workspace never released " +
|
||||
"is removed two hours after its last use.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"workspace": map[string]any{"type": "string", "description": "the workspace id a fetch answered"},
|
||||
}, "required": []string{"workspace"}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
ws, _ := args["workspace"].(string)
|
||||
if err := m.spaces().Release(ws); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"released": ws}, nil
|
||||
}}
|
||||
}
|
||||
|
||||
// sweepSaid is CleanWorkspaces' saying of a workspace a caller never released: a fault of the caller (ADR 0509).
|
||||
func (m *Module) sweepSaid(removed []string) {
|
||||
if len(removed) > 0 && m.Say != nil {
|
||||
m.Say("removed %d workspaces no caller released within %s: %v", len(removed), buildstep.UnheldBound, removed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
const underTheFloor = "refused: ace has 4 GiB free under the floor of 20 GiB"
|
||||
|
||||
func underFloor(node string) buildstep.Floor {
|
||||
f := floorOf(node)
|
||||
f.Free = func(string) (uint64, error) { return 4 << 30, nil }
|
||||
return f
|
||||
}
|
||||
|
||||
// 1839/AC4: the module as main makes it keeps build-agent's floor of 20 GiB.
|
||||
func TestTheModuleKeepsTheFloorOf20GiB(t *testing.T) {
|
||||
if f := floorOf("ace"); f.Bytes != 20<<30 || f.Node != "ace" {
|
||||
t.Errorf("floorOf = %+v, want ace and 20 GiB", f)
|
||||
}
|
||||
}
|
||||
|
||||
// 1839/AC1: under the floor every step tool is refused, naming the node, what is free and the floor, no step runs, and
|
||||
// the module is unhealthy saying so, so the builder chooses another node.
|
||||
func TestUnderTheFloorEveryStepIsRefusedAndTheModuleIsUnhealthy(t *testing.T) {
|
||||
states := newStates()
|
||||
m := moduleWith(states, "v26.10.0")
|
||||
m.Work, m.Floor = t.TempDir(), underFloor("ace")
|
||||
ran := false
|
||||
run := func(context.Context, map[string]any) (any, error) { ran = true; return "done", nil }
|
||||
steps := []StepTool{{Name: "long_probe", Long: true, Run: run}, {Name: "short_probe", Run: run}}
|
||||
for _, tool := range m.Tools(steps) {
|
||||
switch tool.Name {
|
||||
case "long_probe":
|
||||
if _, err := tool.Run(map[string]any{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st := states.waitEnd(t); st.State != delivery.StepRefused || !strings.HasPrefix(st.Reason, underTheFloor) {
|
||||
t.Errorf("the long step ended %s: %q, want refused: %q", st.State, st.Reason, underTheFloor)
|
||||
}
|
||||
case "short_probe":
|
||||
if _, err := tool.Run(map[string]any{}); err == nil || !strings.HasPrefix(err.Error(), underTheFloor) {
|
||||
t.Errorf("the short step answered %v, want %q", err, underTheFloor)
|
||||
}
|
||||
}
|
||||
}
|
||||
if ran {
|
||||
t.Errorf("a step ran under the floor")
|
||||
}
|
||||
if a := m.Health(context.Background()); a.State != health.StateUnhealthy || !strings.HasPrefix(a.Why, underTheFloor) {
|
||||
t.Errorf("health = %+v, want unhealthy: %q", a, underTheFloor)
|
||||
}
|
||||
}
|
||||
|
||||
func releaseTool(t *testing.T, m *Module) func(map[string]any) (any, error) {
|
||||
t.Helper()
|
||||
for _, tool := range m.Tools(m.StepTools()) {
|
||||
if tool.Name == ReleaseTool {
|
||||
return tool.Run
|
||||
}
|
||||
}
|
||||
t.Fatalf("no tool %s", ReleaseTool)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 1839/AC2: build_node_release removes the workspace at once, even under the floor; a second release is no error.
|
||||
func TestReleaseRemovesTheWorkspaceAtOnce(t *testing.T) {
|
||||
m, _, work := newMachine(t)
|
||||
m.Floor = underFloor("ace")
|
||||
ws := filepath.Join(work, "abc1")
|
||||
os.MkdirAll(filepath.Join(ws, "tree"), 0o700)
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, err := releaseTool(t, m)(map[string]any{"workspace": "abc1"}); err != nil {
|
||||
t.Fatalf("release %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(ws); !os.IsNotExist(err) {
|
||||
t.Errorf("the workspace is still there after its release")
|
||||
}
|
||||
}
|
||||
|
||||
// 1839/AC2: a fetch that fails leaves no workspace behind: nobody was told its id, so nobody could release it.
|
||||
func TestAFailedFetchLeavesNoWorkspace(t *testing.T) {
|
||||
m, f := fetchMachine(t, typeScriptModule(`{}`), nil)
|
||||
f.answer["git fetch"] = forgekey.Ran{Status: 128, Stderr: "fatal: unreachable"}
|
||||
if _, err := m.fetch(context.Background(), fetchArgs); err == nil {
|
||||
t.Fatal("the fetch passed, want it failed")
|
||||
}
|
||||
if entries, _ := os.ReadDir(m.Work); len(entries) != 0 {
|
||||
t.Errorf("the failed fetch left %d entries in the work directory", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
// 1839/AC3: a workspace a running step holds is never swept, however old; an unheld one past the two-hour bound is,
|
||||
// also at the module's own round and not only at a fetch.
|
||||
func TestTheSweepKeepsAHeldWorkspaceAndRemovesAnUnheldOnePastTwoHours(t *testing.T) {
|
||||
m := &Module{Work: t.TempDir()}
|
||||
for _, name := range []string{"held1", "lost1"} {
|
||||
os.MkdirAll(filepath.Join(m.Work, name, "tree"), 0o755)
|
||||
}
|
||||
done := m.spaces().Hold("held1")
|
||||
defer done()
|
||||
when := time.Now().Add(-3 * time.Hour)
|
||||
for _, name := range []string{"held1", "lost1"} {
|
||||
os.Chtimes(filepath.Join(m.Work, name), when, when)
|
||||
}
|
||||
m.CleanWorkspaces(time.Now())
|
||||
if _, err := os.Stat(filepath.Join(m.Work, "held1")); err != nil {
|
||||
t.Errorf("the held workspace was swept")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(m.Work, "lost1")); !os.IsNotExist(err) {
|
||||
t.Errorf("the unheld workspace three hours old was kept")
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ func (m *Module) Tools(steps []StepTool) []stdio.Tool {
|
||||
tools = append(tools, t)
|
||||
}
|
||||
tools = append(tools, m.ReadTools()...)
|
||||
tools = append(tools, m.releaseTool())
|
||||
return append(tools,
|
||||
stdio.Tool{Name: StatusTool,
|
||||
Description: "What the Node build module is on this machine: the Node version its recipe builds with and the one installed, " +
|
||||
|
||||
@@ -3,7 +3,7 @@ module meshbuildnode
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023062828-6147a2045467
|
||||
github.com/distribution/distribution/v3 v3.1.2
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 h1:Ufmd2cYEXICQ817IGflccP9bexABE4WhIO49HPbaoSo=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023062828-6147a2045467 h1:i85+fFT0wZiTZ9CRVfMzWg1+Zr7oOLl3UH7hEaKcOIc=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023062828-6147a2045467/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=
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"build_node_fetch",
|
||||
"build_node_test",
|
||||
"build_node_build",
|
||||
"build_node_release",
|
||||
"build_node_step",
|
||||
"build_node_steps",
|
||||
"build_node_toolchains",
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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