Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fe6ea4ee4 |
-18
@@ -7,24 +7,6 @@ modules/jetbrains-toolbox/cmd/toolbox-tools/toolbox-tools
|
||||
modules/messenger/cmd/messenger/messenger
|
||||
modules/mesh-watcher/cmd/mesh-watcher/mesh-watcher
|
||||
modules/claude-code/cmd/claude-code/claude-code
|
||||
modules/dnsmasq/cmd/dnsmasq-tools/dnsmasq-tools
|
||||
# ...and the same built from the module's root (go build ./cmd/<name>), which names it after the module.
|
||||
modules/messenger/messenger
|
||||
modules/claude-code/claude-code
|
||||
modules/mesh-watcher/mesh-watcher
|
||||
modules/dnsmasq/dnsmasq
|
||||
modules/mesh-issues/cmd/mesh-issues/mesh-issues
|
||||
modules/mounts/cmd/mounts/mounts
|
||||
modules/mesh-knowledge/cmd/mesh-knowledge/mesh-knowledge
|
||||
modules/mesh-knowledge/mesh-knowledge
|
||||
modules/mesh-reads/mesh-reads
|
||||
modules/mesh-reads/cmd/mesh-reads/mesh-reads
|
||||
modules/mesh-files/mesh-files
|
||||
modules/mesh-files/cmd/mesh-files/mesh-files
|
||||
|
||||
# The provisioner client built in place (issue 671).
|
||||
modules/mesh-provisioner-client/cmd/mesh-provisioner-client/mesh-provisioner-client
|
||||
modules/brother/cmd/brother/brother
|
||||
modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go
|
||||
modules/mesh-config/mesh-config
|
||||
modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# Release 57: config and provisioning
|
||||
|
||||
This branch carries the work of hq design 57 (ADR 0348, ADR 0349) in this repository. Each package
|
||||
merges here through its own reviewed pull request; the branch reaches main as one pull request, in one
|
||||
delivery group with the branch of the same name in mesh-controller, mesh-host, mesh-sdk, mesh-catalog
|
||||
and hq, after a rehearsal in the lab. This file is deleted by the last commit before that pull request.
|
||||
@@ -1,3 +0,0 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/agenthome
|
||||
|
||||
go 1.22
|
||||
@@ -1,128 +0,0 @@
|
||||
// Command agenthome refuses, in every module's manifest, a resource placed under the agent account's home
|
||||
// that the node-engine cannot write without a gap (novox/hq ADR 0266).
|
||||
//
|
||||
// The agent account owns its home and everything in it, so it can put a symbolic or hard link anywhere
|
||||
// there. The node-engine, as root, writes a whole file or a directory below a home through descriptors it
|
||||
// opened itself and refuses a link it finds; it unpacks an archive, and writes into an existing file (`into`:
|
||||
// json or block), only after checking the path, so a link swapped in between the check and the write would
|
||||
// point root's write at any file. No module does either today. This check keeps it so: a resource of type
|
||||
// `archive`, or a `file` with `into`, whose path is under ${machine:agent-home} — written so, reached through
|
||||
// one of the module's own directories (${dir:<id>}), or written as a literal /home path — is refused. The
|
||||
// node-engine refuses the same below the home of any account declared never to become root, whatever the path.
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AgentHome is the machine fact naming the agent account's home.
|
||||
const AgentHome = "${machine:agent-home}"
|
||||
|
||||
// ofDir is where a manifest names one of its own directories: ${dir:<id>}.
|
||||
var ofDir = regexp.MustCompile(`\$\{dir:([a-z0-9][a-z0-9_-]*)\}`)
|
||||
|
||||
// resolve replaces each ${dir:<id>} with that resource's own path, as far as the manifest says it, so a path
|
||||
// reaching the agent's home through one of the module's directories is seen for what it is.
|
||||
func resolve(p string, dirs map[string]string) string {
|
||||
for i := 0; i < 8 && ofDir.MatchString(p); i++ {
|
||||
p = ofDir.ReplaceAllStringFunc(p, func(word string) string {
|
||||
if to, ok := dirs[ofDir.FindStringSubmatch(word)[1]]; ok {
|
||||
return to
|
||||
}
|
||||
return word
|
||||
})
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// underAHome says whether a resolved path lies under the agent's home: by its machine fact, or written as a
|
||||
// literal home, which no definition may carry anyway (ADR 0112) and which may well be the agent's.
|
||||
func underAHome(p string) bool {
|
||||
// Cleaned first, so //home/x or /./home/x is seen as the /home/x it is.
|
||||
if p != "" {
|
||||
p = filepath.Clean(p)
|
||||
}
|
||||
return strings.HasPrefix(p, AgentHome) || strings.HasPrefix(p, "/home/") || p == "/home"
|
||||
}
|
||||
|
||||
// Finding is one resource refused.
|
||||
type Finding struct {
|
||||
Module, ID, Why string
|
||||
}
|
||||
|
||||
// Check reads every manifest under root/modules and answers what it refuses.
|
||||
func Check(root string) ([]Finding, error) {
|
||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(manifests)
|
||||
var out []Finding
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
dirs := map[string]string{}
|
||||
for _, r := range m.Resources {
|
||||
id, _ := r["id"].(string)
|
||||
if p, _ := r["path"].(string); id != "" && p != "" {
|
||||
dirs[id] = p
|
||||
}
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
p, _ := r["path"].(string)
|
||||
if !underAHome(resolve(p, dirs)) {
|
||||
continue
|
||||
}
|
||||
id, _ := r["id"].(string)
|
||||
kind, _ := r["type"].(string)
|
||||
into, _ := r["into"].(string)
|
||||
switch {
|
||||
case kind == "archive":
|
||||
out = append(out, Finding{m.Module, id, "an archive unpacked under the agent's home"})
|
||||
case kind == "file" && into != "":
|
||||
out = append(out, Finding{m.Module, id, "a file written into (" + into + ") under the agent's home"})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
root := "."
|
||||
if len(os.Args) > 1 {
|
||||
root = os.Args[1]
|
||||
}
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, f := range found {
|
||||
fmt.Printf("%s: %s is %s: the node-engine writes it as root after checking the path, and the agent "+
|
||||
"account could swap a link in between (novox/hq ADR 0266); place a whole file or a directory instead\n",
|
||||
f.Module, f.ID, f.Why)
|
||||
}
|
||||
if len(found) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("agent-home: no archive and no file written into under the agent's home")
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func manifest(t *testing.T, root, module, resources string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, "modules", module)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := `{"module":"` + module + `","resources":[` + resources + `]}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnArchiveOrAFileWrittenIntoUnderTheAgentsHomeIsRefused(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "a", `{"id":"theme","type":"archive","path":"${machine:agent-home}/.local/share/x"}`)
|
||||
manifest(t, root, "b", `{"id":"cfg","type":"file","path":"${machine:agent-home}/.claude.json","into":"json","content":"{}"}`)
|
||||
manifest(t, root, "c", `{"id":"dir","type":"directory","path":"${machine:agent-home}/.claude"},`+
|
||||
`{"id":"whole","type":"file","path":"${machine:agent-home}/x","content":"y"},`+
|
||||
`{"id":"op","type":"archive","path":"${machine:account-home}/.local/share/x"}`)
|
||||
manifest(t, root, "d", `{"id":"home","type":"directory","path":"${machine:agent-home}/.claude"},`+
|
||||
`{"id":"inner","type":"directory","path":"${dir:home}/inner"},`+
|
||||
`{"id":"via-dir","type":"archive","path":"${dir:inner}/theme"}`)
|
||||
manifest(t, root, "e", `{"id":"literal","type":"file","path":"/home/agent/.bashrc","into":"block","content":"x"}`)
|
||||
manifest(t, root, "f", `{"id":"unclean","type":"archive","path":"//./home/agent/x"}`)
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 5 || found[0].Module != "a" || found[1].Module != "b" || found[2].ID != "via-dir" ||
|
||||
found[3].ID != "literal" || found[4].ID != "unclean" {
|
||||
t.Fatalf("refused %+v", found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCatalogueHasNone(t *testing.T) {
|
||||
found, err := Check("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 0 {
|
||||
t.Fatalf("%+v", found)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/config
|
||||
|
||||
go 1.22
|
||||
@@ -1,201 +0,0 @@
|
||||
// Command config refuses the settings form's names in a module that has moved to config (novox/hq ADR 0348
|
||||
// clauses 1 and 11, design 57 package K6, issue 665).
|
||||
//
|
||||
// "Config" replaces "settings": a manifest declares its values under `config`, a mergeable file names the keys it
|
||||
// takes under `takes-config`, and a file asks for a value with `${config:<key>}`. A mergeable file naming no keys at
|
||||
// all is the settings form's last shape: it takes every key of its module's layer and declares none, so mesh-config
|
||||
// could set none of them (ADR 0348 §10, ADR 0326 §3); it is found here like an old name (issue 735). The old names — `settings`,
|
||||
// `takes-settings` and `${setting:<key>}` — stay accepted by the controller as aliases until every module has
|
||||
// moved, and are then refused (ADR 0348 §1). The modules not yet moved are listed in the file `unmoved` beside this
|
||||
// check, one per line: the catalogue's list ADR 0348 §11 names. Every other module says no old name, in its
|
||||
// manifest or in a template file its manifest names; a module on the list that says none is taken off it; and a
|
||||
// line the trunk's copy (the merge base's, given with -base) does not hold is refused, so the list only shrinks;
|
||||
// while the trunk holds no copy, the modules in seed are the trunk's.
|
||||
// When it is empty, the controller refuses the old names itself (design 57 package R1).
|
||||
//
|
||||
// Prose is not judged: a comment in a file's text that says "settings" composes into the machine's file, and
|
||||
// rewording it would change what a node is sent.
|
||||
//
|
||||
// go run . [-base <the trunk's unmoved>] <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The settings form's names (ADR 0348 §1). A key of a JSON object is a quoted string followed by a colon; a
|
||||
// string value is never followed by one.
|
||||
var (
|
||||
settingRef = regexp.MustCompile(`\$\{setting:[a-z0-9][a-z0-9_.-]*\}`)
|
||||
takesSettingsKey = regexp.MustCompile(`"takes-settings"\s*:`)
|
||||
oldPlaceholder = "${setting:…}"
|
||||
oldTakes = "takes-settings"
|
||||
oldDeclaration = "settings"
|
||||
wholeLayer = "taking its whole layer"
|
||||
listFile = "unmoved"
|
||||
listPathInCatalog = "checks/config/" + listFile
|
||||
)
|
||||
|
||||
// Check answers, per module that says any, the settings form's names its manifest and its template files say.
|
||||
func Check(root string) (map[string][]string, error) {
|
||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(manifests)
|
||||
out := map[string][]string{}
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Settings json.RawMessage `json:"settings"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
var said []string
|
||||
if len(m.Settings) > 0 && string(m.Settings) != "null" {
|
||||
said = append(said, oldDeclaration)
|
||||
}
|
||||
if takesSettingsKey.Match(raw) {
|
||||
said = append(said, oldTakes)
|
||||
}
|
||||
if settingRef.Match(raw) {
|
||||
said = append(said, oldPlaceholder)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
// A mergeable file naming no keys takes every key of the layer (ADR 0326 §3), declaring none of them.
|
||||
if how, _ := r["merge"].(string); how != "" {
|
||||
_, oldList := r["takes-settings"]
|
||||
_, newList := r["takes-config"]
|
||||
if !oldList && !newList {
|
||||
said = append(said, fmt.Sprintf("%v %s", r["id"], wholeLayer))
|
||||
}
|
||||
}
|
||||
name, _ := r["template"].(string)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
text, err := os.ReadFile(filepath.Join(filepath.Dir(path), filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s names the template %s: %w", m.Module, name, err)
|
||||
}
|
||||
if settingRef.Match(text) {
|
||||
said = append(said, oldPlaceholder+" in "+name)
|
||||
}
|
||||
}
|
||||
if len(said) > 0 {
|
||||
sort.Strings(said)
|
||||
out[m.Module] = said
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// seed is the list as it was first written, standing for the trunk's copy until the trunk holds one.
|
||||
// The modules taking a whole layer were found after the list was first written and before the trunk held a copy
|
||||
// (issue 735), so they stand in it too.
|
||||
var seed = map[string]bool{
|
||||
"betterbird": true, "fail2ban": true, "influxdb": true, "keycloak": true, "mailu": true, "searxng": true,
|
||||
"baserow": true, "confluence": true, "gitea": true, "gitlab": true, "grafana": true, "home-assistant": true,
|
||||
"icecast": true, "jira": true, "letta": true, "matrix": true, "nodered": true, "supabase": true, "unifi": true,
|
||||
}
|
||||
|
||||
// Unmoved is the modules a list names, one per line; a line starting with # and a blank line are not modules.
|
||||
func Unmoved(path string) (map[string]bool, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
out := map[string]bool{}
|
||||
s := bufio.NewScanner(f)
|
||||
for s.Scan() {
|
||||
line := strings.TrimSpace(s.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
out[line] = true
|
||||
}
|
||||
return out, s.Err()
|
||||
}
|
||||
|
||||
// Judge answers the modules that say an old name and are not on the list (refused), the modules on the list that
|
||||
// say none (stale), and the modules on the list that the trunk's list does not hold (added).
|
||||
func Judge(found map[string][]string, unmoved, base map[string]bool) (refused, stale, added []string) {
|
||||
for m := range found {
|
||||
if !unmoved[m] {
|
||||
refused = append(refused, m)
|
||||
}
|
||||
}
|
||||
for m := range unmoved {
|
||||
if _, says := found[m]; !says {
|
||||
stale = append(stale, m)
|
||||
}
|
||||
if !base[m] {
|
||||
added = append(added, m)
|
||||
}
|
||||
}
|
||||
sort.Strings(refused)
|
||||
sort.Strings(stale)
|
||||
sort.Strings(added)
|
||||
return refused, stale, added
|
||||
}
|
||||
|
||||
func main() {
|
||||
basePath := flag.String("base", "", "the trunk's copy of unmoved (the merge base's); seed when absent")
|
||||
flag.Parse()
|
||||
root := "."
|
||||
if flag.NArg() > 0 {
|
||||
root = flag.Arg(0)
|
||||
}
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
unmoved, err := Unmoved(listFile)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
base := seed
|
||||
if *basePath != "" {
|
||||
if base, err = Unmoved(*basePath); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
refused, stale, added := Judge(found, unmoved, base)
|
||||
for _, m := range refused {
|
||||
fmt.Printf("%s says %s, the settings form's names, and is not on %s: write config, takes-config and "+
|
||||
"${config:<key>} throughout, and name the keys each mergeable file takes (novox/hq ADR 0348 §1, §11)\n", m, strings.Join(found[m], ", "), listPathInCatalog)
|
||||
}
|
||||
for _, m := range stale {
|
||||
fmt.Printf("%s names %s, which says no old name: take it off the list\n", listPathInCatalog, m)
|
||||
}
|
||||
for _, m := range added {
|
||||
fmt.Printf("%s gains %s, which the trunk's copy does not hold: the list only shrinks — move the module to "+
|
||||
"config instead (novox/hq ADR 0348 §11)\n", listPathInCatalog, m)
|
||||
}
|
||||
if len(refused) > 0 || len(stale) > 0 || len(added) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("config: no moved module says settings, takes-settings or ${setting:<key>}, or takes a whole layer "+
|
||||
"(%d not yet moved)\n",
|
||||
len(unmoved))
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func module(t *testing.T, root, name, body string, files map[string]string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, "modules", name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for path, text := range files {
|
||||
full := filepath.Join(dir, filepath.FromSlash(path))
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(text), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Each of the settings form's names is found where it stands: the declaration, a mergeable file's keys, a
|
||||
// placeholder in the manifest, and a placeholder in a template file the manifest names (novox/hq ADR 0348 §1).
|
||||
func TestEveryOldNameIsFound(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
module(t, root, "declares", `{"module":"declares","settings":{"size":{"kind":"preference","default":1,"why":"w"}},`+
|
||||
`"resources":[{"id":"f","type":"file","content":"${setting:size}"}]}`, nil)
|
||||
module(t, root, "takes", `{"module":"takes","resources":[{"id":"f","type":"file","merge":"json",`+
|
||||
`"takes-settings":["a"],"content":"{}"}]}`, nil)
|
||||
module(t, root, "asks", `{"module":"asks","serves":{"x":{"d":"${setting:domain}"}}}`, nil)
|
||||
module(t, root, "templated", `{"module":"templated","config":{"size":{"type":"number","description":"d",`+
|
||||
`"default":1}},"resources":[{"id":"f","type":"file","template":"files/conf"}]}`,
|
||||
map[string]string{"files/conf": "size = ${setting:size}\n"})
|
||||
module(t, root, "moved", `{"module":"moved","config":{"size":{"type":"number","description":"d","default":1}},`+
|
||||
`"resources":[{"id":"f","type":"file","template":"files/conf"},{"id":"g","type":"file","merge":"json",`+
|
||||
`"takes-config":[],"content":"{\"settings\": 1}"}]}`,
|
||||
map[string]string{"files/conf": "size = ${config:size}\n# change the settings, never this file\n"})
|
||||
module(t, root, "none", `{"module":"none","resources":[{"id":"f","type":"file","content":"no settings here"}]}`, nil)
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string][]string{
|
||||
"asks": {"${setting:…}"},
|
||||
"declares": {"${setting:…}", "settings"},
|
||||
"takes": {"takes-settings"},
|
||||
"templated": {"${setting:…} in files/conf"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("found %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A module not on the list says no old name; one on the list that says none is taken off it; a line the trunk's
|
||||
// list does not hold is refused, so the list only shrinks until it is empty (novox/hq ADR 0348 §11).
|
||||
func TestTheListOfModulesNotYetMovedOnlyShrinks(t *testing.T) {
|
||||
found := map[string][]string{"old": {"settings"}, "forgotten": {"takes-settings"}}
|
||||
unmoved := map[string]bool{"old": true, "done": true, "new": true}
|
||||
base := map[string]bool{"old": true, "done": true, "forgotten": true}
|
||||
refused, stale, added := Judge(found, unmoved, base)
|
||||
if !reflect.DeepEqual(refused, []string{"forgotten"}) || !reflect.DeepEqual(stale, []string{"done", "new"}) ||
|
||||
!reflect.DeepEqual(added, []string{"new"}) {
|
||||
t.Fatalf("refused %v, stale %v, added %v", refused, stale, added)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheListIsReadWithoutItsComments(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "unmoved")
|
||||
if err := os.WriteFile(path, []byte("# why\n\nmailu\n searxng \n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Unmoved(path)
|
||||
if err != nil || !reflect.DeepEqual(got, map[string]bool{"mailu": true, "searxng": true}) {
|
||||
t.Fatalf("%v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue itself: every moved module says config alone, and the list names exactly the modules that
|
||||
// still say settings.
|
||||
func TestTheCatalogueSaysConfigInEveryMovedModule(t *testing.T) {
|
||||
found, err := Check(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unmoved, err := Unmoved("unmoved")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refused, stale, added := Judge(found, unmoved, seed)
|
||||
for _, m := range refused {
|
||||
t.Errorf("%s is not on checks/config/unmoved and says %s", m, strings.Join(found[m], ", "))
|
||||
}
|
||||
for _, m := range stale {
|
||||
t.Errorf("checks/config/unmoved names %s, which says no old name", m)
|
||||
}
|
||||
for _, m := range added {
|
||||
t.Errorf("checks/config/unmoved gains %s, which its first copy did not hold", m)
|
||||
}
|
||||
}
|
||||
|
||||
// A mergeable file that names no keys takes every key of its module's layer (novox/hq ADR 0326 §3): the settings
|
||||
// form's last shape, which declares none of what it takes, so mesh-config could set none of it (ADR 0348 §10). It
|
||||
// is found like an old name, in a module that says no other (issue 735); one naming its keys, an empty list
|
||||
// included, is not.
|
||||
func TestAMergeableFileTakingTheWholeLayerIsFound(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
module(t, root, "whole", `{"module":"whole","resources":[{"id":"runtime-config","type":"file","merge":"json",`+
|
||||
`"template":"files/config.json"}]}`, map[string]string{"files/config.json": "{}\n"})
|
||||
module(t, root, "names", `{"module":"names","config":{"url":{"type":"string","description":"d","default":""}},`+
|
||||
`"resources":[{"id":"runtime-config","type":"file","merge":"json","takes-config":["url"],"content":"{}"}]}`, nil)
|
||||
module(t, root, "none", `{"module":"none","resources":[{"id":"c","type":"file","merge":"json",`+
|
||||
`"takes-config":[],"content":"{}"}]}`, nil)
|
||||
module(t, root, "plain", `{"module":"plain","resources":[{"id":"c","type":"file","content":"{}"}]}`, nil)
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string][]string{"whole": {"runtime-config taking its whole layer"}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("found %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
# The modules still written in the settings form (`settings`, `takes-settings`, `${setting:<key>}`, or a mergeable
|
||||
# file naming no keys, which takes its whole layer), one per line: the catalogue's list of modules not yet moved to
|
||||
# config (novox/hq ADR 0348 §11). Every other module says config alone. This list only shrinks: a module that says no
|
||||
# old name is taken off, and a line the trunk's copy does not hold is refused. When it is empty, the controller
|
||||
# refuses the old names (design 57 package R1).
|
||||
@@ -1,3 +0,0 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/deps
|
||||
|
||||
go 1.22
|
||||
@@ -1,255 +0,0 @@
|
||||
// Command deps makes a module's dependencies available to the catalogue's check, or says why it cannot
|
||||
// (novox/hq issue 385).
|
||||
//
|
||||
// A module's tests run only when its dependencies can be fetched. The build seat clones what a check
|
||||
// needs with the forge credential and forgets that credential before the check runs, so a module pinning
|
||||
// an SDK commit the seat's module cache does not hold cannot fetch it from the forge. The SDK is cloned
|
||||
// beside the check all the same (MESH_CHECK_BESIDE, mesh-controller's besideRefs): every requirement of the
|
||||
// mesh's own forge is taken from the clone of its repository there, at the commit or the tag its version
|
||||
// names, through a go.mod of its own (-modfile) — the module's own files are not changed, and no forge
|
||||
// credential is added.
|
||||
//
|
||||
// Usage: deps -beside <dir> -work <dir> <module dir>. It prints the go.mod to build the module with, or
|
||||
// nothing when the module fetches what it needs; when it cannot, it prints why and exits 1, and the check
|
||||
// fails: a module whose tests could not run is a failure, never a note.
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// forge is the prefix of the Go modules of the mesh's own forge: <forge><repository>[/<directory>].
|
||||
const forge = "git.novox.be/novox/"
|
||||
|
||||
func main() {
|
||||
beside := flag.String("beside", "", "where the repositories beside the check are cloned (MESH_CHECK_BESIDE)")
|
||||
work := flag.String("work", os.TempDir(), "where the go.mod and the sources taken from beside are written")
|
||||
flag.Parse()
|
||||
if flag.NArg() != 1 {
|
||||
fmt.Println("usage: deps -beside <dir> -work <dir> <module dir>")
|
||||
os.Exit(2)
|
||||
}
|
||||
modfile, err := resolve(flag.Arg(0), *beside, *work)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if modfile != "" {
|
||||
fmt.Println(modfile)
|
||||
}
|
||||
}
|
||||
|
||||
// resolve is the go.mod the module at dir builds with: "" when its own fetches everything, else one
|
||||
// beside it in work that takes every requirement of the mesh's forge from its clone beside the check.
|
||||
func resolve(dir, beside, work string) (string, error) {
|
||||
out, err := goIn(dir, "mod", "download")
|
||||
if err == nil {
|
||||
return "", nil
|
||||
}
|
||||
fetching := lastLine(out)
|
||||
required, err := forgeRequirements(dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(required) == 0 {
|
||||
return "", fmt.Errorf("its dependencies cannot be fetched: %s", fetching)
|
||||
}
|
||||
if beside == "" {
|
||||
return "", fmt.Errorf("its dependencies cannot be fetched (%s), and nothing was cloned beside the check to "+
|
||||
"take %s from", fetching, repositories(required))
|
||||
}
|
||||
into, err := os.MkdirTemp(work, filepath.Base(dir)+"-")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
modfile := filepath.Join(into, "go.mod")
|
||||
if err := copyFile(filepath.Join(dir, "go.mod"), modfile); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := copyFile(filepath.Join(dir, "go.sum"), filepath.Join(into, "go.sum")); err != nil &&
|
||||
!errors.Is(err, os.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
for _, r := range required {
|
||||
source, err := export(beside, r, into)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("its dependencies cannot be fetched (%s), and %s@%s cannot be taken from the "+
|
||||
"clone beside the check: %w", fetching, r.Path, r.Version, err)
|
||||
}
|
||||
if out, err := goIn(dir, "mod", "edit", "-replace", r.Path+"@"+r.Version+"="+source, modfile); err != nil {
|
||||
return "", fmt.Errorf("%s@%s cannot be taken from the clone beside the check: %s", r.Path, r.Version,
|
||||
lastLine(out))
|
||||
}
|
||||
}
|
||||
if out, err := goIn(dir, "mod", "download", "-modfile="+modfile); err != nil {
|
||||
return "", fmt.Errorf("its dependencies cannot be fetched even with %s taken from the clone beside the "+
|
||||
"check: %s", repositories(required), lastLine(out))
|
||||
}
|
||||
return modfile, nil
|
||||
}
|
||||
|
||||
// requirement is one module the module requires.
|
||||
type requirement struct {
|
||||
Path string
|
||||
Version string
|
||||
}
|
||||
|
||||
// forgeRequirements is every requirement of the module at dir that comes from the mesh's own forge.
|
||||
func forgeRequirements(dir string) ([]requirement, error) {
|
||||
out, err := goIn(dir, "mod", "edit", "-json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("its go.mod cannot be read: %s", lastLine(out))
|
||||
}
|
||||
var mod struct{ Require []requirement }
|
||||
if err := json.Unmarshal(out, &mod); err != nil {
|
||||
return nil, fmt.Errorf("its go.mod cannot be read: %w", err)
|
||||
}
|
||||
var on []requirement
|
||||
for _, r := range mod.Require {
|
||||
if strings.HasPrefix(r.Path, forge) {
|
||||
on = append(on, r)
|
||||
}
|
||||
}
|
||||
return on, nil
|
||||
}
|
||||
|
||||
// repositoryOf splits a module path of the forge into its repository and the directory in it.
|
||||
func repositoryOf(path string) (string, string) {
|
||||
repo, sub, _ := strings.Cut(strings.TrimPrefix(path, forge), "/")
|
||||
return repo, sub
|
||||
}
|
||||
|
||||
func repositories(rs []requirement) string {
|
||||
var names []string
|
||||
for _, r := range rs {
|
||||
repo, _ := repositoryOf(r.Path)
|
||||
names = append(names, repo)
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
// pseudo is a pseudo-version's commit: v0.1.14-0.20261012082113-9747f3e1b1ac → 9747f3e1b1ac.
|
||||
var pseudo = regexp.MustCompile(`[-.]\d{14}-([0-9a-f]{12})$`)
|
||||
|
||||
// refOf is the ref a version names in its repository: a pseudo-version's commit, else the tag a module in
|
||||
// a directory is released under (<directory>/<version>).
|
||||
func refOf(sub, version string) string {
|
||||
version = strings.TrimSuffix(version, "+incompatible")
|
||||
if m := pseudo.FindStringSubmatch(version); m != nil {
|
||||
return m[1]
|
||||
}
|
||||
if sub != "" {
|
||||
return sub + "/" + version
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
// export writes the module r names, at its version, from its repository's clone beside the check into
|
||||
// into, and answers its directory.
|
||||
func export(beside string, r requirement, into string) (string, error) {
|
||||
repo, sub := repositoryOf(r.Path)
|
||||
clone := filepath.Join(beside, repo)
|
||||
if _, err := os.Stat(filepath.Join(clone, ".git")); err != nil {
|
||||
return "", fmt.Errorf("no clone of %s is beside the check", repo)
|
||||
}
|
||||
ref := refOf(sub, r.Version)
|
||||
commit, err := exec.Command("git", "-C", clone, "rev-parse", "--verify", "--quiet", ref+"^{commit}").Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the clone of %s holds no %s (a commit or tag not pushed to the forge?)", repo, ref)
|
||||
}
|
||||
at := strings.TrimSpace(string(commit))
|
||||
args := []string{"-C", clone, "archive", "--format=tar", at}
|
||||
if sub != "" {
|
||||
args = append(args, "--", sub)
|
||||
}
|
||||
var stderr bytes.Buffer
|
||||
archive := exec.Command("git", args...)
|
||||
archive.Stderr = &stderr
|
||||
raw, err := archive.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("git archive of %s at %s: %v %s", repo, ref, err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
root := filepath.Join(into, repo+"@"+at[:12])
|
||||
if err := untar(raw, root); err != nil {
|
||||
return "", err
|
||||
}
|
||||
source := filepath.Join(root, filepath.FromSlash(sub))
|
||||
if _, err := os.Stat(filepath.Join(source, "go.mod")); err != nil {
|
||||
return "", fmt.Errorf("%s at %s has no go.mod in %q", repo, ref, sub)
|
||||
}
|
||||
return source, nil
|
||||
}
|
||||
|
||||
// untar writes a tar's directories and regular files under root.
|
||||
func untar(raw []byte, root string) error {
|
||||
r := tar.NewReader(bytes.NewReader(raw))
|
||||
for {
|
||||
h, err := r.Next()
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := filepath.Clean(filepath.FromSlash(h.Name))
|
||||
if name == "." || strings.HasPrefix(name, "..") || filepath.IsAbs(name) {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(root, name)
|
||||
switch h.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(path, body, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func copyFile(from, to string) error {
|
||||
raw, err := os.ReadFile(from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(to, raw, 0o644)
|
||||
}
|
||||
|
||||
// goIn runs the go command in dir.
|
||||
func goIn(dir string, args ...string) ([]byte, error) {
|
||||
cmd := exec.Command("go", args...)
|
||||
cmd.Dir = dir
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return append(out, stderr.Bytes()...), err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// lastLine is the last line a command said, which is where go says what failed.
|
||||
func lastLine(out []byte) string {
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
return strings.TrimSpace(lines[len(lines)-1])
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// offline is a Go that fetches nothing: what the build seat is once the forge credential is gone, for a pin
|
||||
// its module cache does not hold (novox/hq issue 385).
|
||||
func offline(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("GOPROXY", "off")
|
||||
t.Setenv("GOFLAGS", "")
|
||||
t.Setenv("GOWORK", "off")
|
||||
// Not GOPRIVATE: a private module is fetched directly by git, around GOPROXY, with whatever credential
|
||||
// the machine running the test holds.
|
||||
t.Setenv("GOPRIVATE", "")
|
||||
t.Setenv("GONOPROXY", "")
|
||||
t.Setenv("GOSUMDB", "off")
|
||||
t.Setenv("GOMODCACHE", t.TempDir())
|
||||
}
|
||||
|
||||
func git(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t",
|
||||
"GIT_COMMITTER_EMAIL=t@t", "GIT_AUTHOR_DATE=2026-10-12T08:21:13Z", "GIT_COMMITTER_DATE=2026-10-12T08:21:13Z")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func write(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// sdkBeside is the SDK's repository cloned beside a check, its Go module under go/, one commit tagged
|
||||
// go/v0.1.7. It answers the beside root and the commit.
|
||||
func sdkBeside(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
beside := t.TempDir()
|
||||
repo := filepath.Join(beside, "mesh-sdk")
|
||||
write(t, filepath.Join(repo, "go", "go.mod"), "module git.novox.be/novox/mesh-sdk/go\n\ngo 1.22\n")
|
||||
write(t, filepath.Join(repo, "go", "sdk", "sdk.go"), "package sdk\n\nfunc Hello() string { return \"hello\" }\n")
|
||||
write(t, filepath.Join(repo, "README.md"), "the SDK\n")
|
||||
git(t, repo, "init", "--quiet")
|
||||
git(t, repo, "add", ".")
|
||||
git(t, repo, "commit", "--quiet", "-m", "sdk")
|
||||
git(t, repo, "tag", "go/v0.1.7")
|
||||
return beside, git(t, repo, "rev-parse", "HEAD")
|
||||
}
|
||||
|
||||
// moduleRequiring is a catalogue module whose code and test use the SDK at the version given.
|
||||
func moduleRequiring(t *testing.T, version string) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(t.TempDir(), "messenger")
|
||||
write(t, filepath.Join(dir, "go.mod"), "module git.novox.be/novox/mesh-catalog/modules/messenger\n\ngo 1.22\n\n"+
|
||||
"require git.novox.be/novox/mesh-sdk/go "+version+"\n")
|
||||
write(t, filepath.Join(dir, "main.go"), "package main\n\nimport \"git.novox.be/novox/mesh-sdk/go/sdk\"\n\n"+
|
||||
"func main() { println(sdk.Hello()) }\n")
|
||||
return dir
|
||||
}
|
||||
|
||||
func builds(t *testing.T, dir, modfile string) {
|
||||
t.Helper()
|
||||
args := []string{"vet"}
|
||||
if modfile != "" {
|
||||
args = append(args, "-modfile="+modfile)
|
||||
}
|
||||
cmd := exec.Command("go", append(args, "./...")...)
|
||||
cmd.Dir = dir
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("the module does not build with %q: %v\n%s", modfile, err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// A module that needs nothing it cannot fetch is left as it is: no other go.mod.
|
||||
func TestAModuleThatFetchesItsDependenciesNeedsNothingBeside(t *testing.T) {
|
||||
offline(t)
|
||||
dir := filepath.Join(t.TempDir(), "plain")
|
||||
write(t, filepath.Join(dir, "go.mod"), "module example.com/plain\n\ngo 1.22\n")
|
||||
write(t, filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n")
|
||||
modfile, err := resolve(dir, "", t.TempDir())
|
||||
if err != nil || modfile != "" {
|
||||
t.Fatalf("resolve = %q, %v; want nothing", modfile, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The case of issue 385: the module pins an SDK commit the build seat cannot fetch. It is taken from the
|
||||
// SDK's clone beside the check, and the module builds against it.
|
||||
func TestAPinnedSDKCommitIsTakenFromTheCloneBeside(t *testing.T) {
|
||||
offline(t)
|
||||
beside, commit := sdkBeside(t)
|
||||
dir := moduleRequiring(t, "v0.1.14-0.20261012082113-"+commit[:12])
|
||||
modfile, err := resolve(dir, beside, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if modfile == "" {
|
||||
t.Fatal("resolve said the module fetches its dependencies, with nothing to fetch them from")
|
||||
}
|
||||
builds(t, dir, modfile)
|
||||
if raw, _ := os.ReadFile(filepath.Join(dir, "go.mod")); strings.Contains(string(raw), "replace") {
|
||||
t.Fatalf("the module's own go.mod was changed:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// A released SDK is found by the tag it is released under: go/<version>.
|
||||
func TestATaggedSDKIsTakenFromTheCloneBeside(t *testing.T) {
|
||||
offline(t)
|
||||
beside, _ := sdkBeside(t)
|
||||
dir := moduleRequiring(t, "v0.1.7")
|
||||
modfile, err := resolve(dir, beside, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
builds(t, dir, modfile)
|
||||
}
|
||||
|
||||
// A commit the clone beside does not hold (a branch never pushed) cannot be tested, and says which.
|
||||
func TestACommitTheCloneBesideDoesNotHoldIsSaid(t *testing.T) {
|
||||
offline(t)
|
||||
beside, _ := sdkBeside(t)
|
||||
dir := moduleRequiring(t, "v0.1.14-0.20261010132341-4f3efc98e0c0")
|
||||
_, err := resolve(dir, beside, t.TempDir())
|
||||
if err == nil {
|
||||
t.Fatal("resolve found a commit nothing holds")
|
||||
}
|
||||
for _, want := range []string{"git.novox.be/novox/mesh-sdk/go@v0.1.14-0.20261010132341-4f3efc98e0c0", "4f3efc98e0c0"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the reason %q does not name %s", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// With nothing cloned beside (a check by hand without the forge), the reason says that.
|
||||
func TestNoCloneBesideIsSaid(t *testing.T) {
|
||||
offline(t)
|
||||
_, commit := sdkBeside(t)
|
||||
dir := moduleRequiring(t, "v0.1.14-0.20261012082113-"+commit[:12])
|
||||
for _, beside := range []string{"", t.TempDir()} {
|
||||
_, err := resolve(dir, beside, t.TempDir())
|
||||
if err == nil {
|
||||
t.Fatalf("resolve with %q beside found what is not there", beside)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mesh-sdk") {
|
||||
t.Errorf("the reason %q does not name the repository it needed", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRefOfAVersion(t *testing.T) {
|
||||
cases := []struct{ sub, version, want string }{
|
||||
{"go", "v0.1.14-0.20261012082113-9747f3e1b1ac", "9747f3e1b1ac"},
|
||||
{"go", "v0.0.0-20261012082113-9747f3e1b1ac", "9747f3e1b1ac"},
|
||||
{"go", "v0.1.7", "go/v0.1.7"},
|
||||
{"", "v1.2.0", "v1.2.0"},
|
||||
{"", "v2.0.0+incompatible", "v2.0.0"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := refOf(c.sub, c.version); got != c.want {
|
||||
t.Errorf("refOf(%q, %q) = %q, want %q", c.sub, c.version, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/imagebuild
|
||||
|
||||
go 1.22
|
||||
@@ -1,92 +0,0 @@
|
||||
// Command imagebuild refuses any manifest but modules/buildah providing the rootless-image-build provision
|
||||
// (novox/hq ADR 0487 §3, issue 1704): the image builder's tool is buildah alone, and docker, whose daemon holds
|
||||
// the machine's root, never stands in for it. A second provider would let a node compose mesh-build-image over
|
||||
// something that is not the rootless builder it was designed against.
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Provision is the provision only Provider may provide.
|
||||
const Provision = "rootless-image-build"
|
||||
|
||||
// Provider is the one module allowed to provide it.
|
||||
const Provider = "buildah"
|
||||
|
||||
// Finding is one manifest refused: a module other than buildah providing the provision.
|
||||
type Finding struct {
|
||||
Module string
|
||||
}
|
||||
|
||||
// Check reads every manifest under root/modules and answers which ones it refuses, and whether buildah provides.
|
||||
func Check(root string) ([]Finding, error) {
|
||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(manifests)
|
||||
var out []Finding
|
||||
provided := false
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Provides []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"provides"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
for _, p := range m.Provides {
|
||||
if p.Name != Provision {
|
||||
continue
|
||||
}
|
||||
if m.Module == Provider {
|
||||
provided = true
|
||||
} else {
|
||||
out = append(out, Finding{Module: m.Module})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !provided {
|
||||
out = append(out, Finding{Module: Provider})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
root := "."
|
||||
if len(os.Args) > 1 {
|
||||
root = os.Args[1]
|
||||
}
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, f := range found {
|
||||
if f.Module == Provider {
|
||||
fmt.Printf("%s does not provide %s (novox/hq ADR 0487 §3)\n", Provider, Provision)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%s provides %s: only %s may, and docker never (novox/hq ADR 0487 §3)\n", f.Module, Provision, Provider)
|
||||
}
|
||||
if len(found) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("imagebuild: rootless-image-build is provided only by buildah")
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func catalogue(t *testing.T, manifests map[string]string) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for name, body := range manifests {
|
||||
dir := filepath.Join(root, "modules", name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
const buildah = `{"module":"buildah","provides":[{"name":"rootless-image-build","reach":"machine"}]}`
|
||||
|
||||
func TestOnlyBuildahProvidesTheRootlessImageBuild(t *testing.T) {
|
||||
root := catalogue(t, map[string]string{"buildah": buildah, "go": `{"module":"go","provides":[{"name":"go-toolchain"}]}`})
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("found %v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerAndAnySecondProviderAreRefused(t *testing.T) {
|
||||
root := catalogue(t, map[string]string{
|
||||
"buildah": buildah,
|
||||
"docker": `{"module":"docker","provides":[{"name":"rootless-image-build"}]}`,
|
||||
"other": `{"module":"other","provides":[{"name":"x"},{"name":"rootless-image-build"}]}`,
|
||||
})
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []Finding{{Module: "docker"}, {Module: "other"}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("found %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildahMustProvideIt(t *testing.T) {
|
||||
root := catalogue(t, map[string]string{"buildah": `{"module":"buildah"}`})
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []Finding{{Module: "buildah"}}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("found %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue as it stands: this is the check itself, run here too.
|
||||
func TestTheCatalogueGivesTheProvisionToBuildahAlone(t *testing.T) {
|
||||
got, err := Check("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("the catalogue's providers of rootless-image-build are wrong: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/imageplace
|
||||
|
||||
go 1.22
|
||||
@@ -1,91 +0,0 @@
|
||||
// Command imageplace refuses an image artifact whose Dockerfile is not a file inside its own module's directory
|
||||
// (novox/hq ADR 0487 §1, issue 1707): the image builder builds a module from that module's directory alone, so
|
||||
// a `from` that leaves it (../other/Dockerfile) or names a file that is not there could never build.
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Finding is one image refused: Module's artifact Artifact names From, and Why says what is wrong with it.
|
||||
type Finding struct {
|
||||
Module, Artifact, From, Why string
|
||||
}
|
||||
|
||||
// Check reads every manifest under root/modules and answers which images it refuses.
|
||||
func Check(root string) ([]Finding, error) {
|
||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(manifests)
|
||||
var out []Finding
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Build struct {
|
||||
Artifacts []struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
From string `json:"from"`
|
||||
} `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a.Kind != "image" {
|
||||
continue
|
||||
}
|
||||
f := Finding{Module: m.Module, Artifact: a.Name, From: a.From}
|
||||
clean := filepath.Clean(a.From)
|
||||
switch {
|
||||
case a.From == "" || filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)):
|
||||
f.Why = "is not a path inside the module's own directory"
|
||||
default:
|
||||
info, err := os.Stat(filepath.Join(dir, clean))
|
||||
if err != nil || info.IsDir() {
|
||||
f.Why = "is not a file in the module's directory"
|
||||
}
|
||||
}
|
||||
if f.Why != "" {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
root := "."
|
||||
if len(os.Args) > 1 {
|
||||
root = os.Args[1]
|
||||
}
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, f := range found {
|
||||
fmt.Printf("%s: image %s builds from %q, which %s (novox/hq ADR 0487 §1)\n", f.Module, f.Artifact, f.From, f.Why)
|
||||
}
|
||||
if len(found) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("imageplace: every image builds from a Dockerfile in its own module's directory")
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// module writes modules/<name>/module.json with one image artifact from `from`, and the files named.
|
||||
func module(t *testing.T, root, name, from string, files ...string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, "modules", name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := `{"module":"` + name + `","build":{"artifacts":[{"name":"server","kind":"image","from":"` + from + `"},{"name":"files","kind":"archive","from":"../elsewhere"}]}}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range files {
|
||||
p := filepath.Join(dir, f)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte("FROM scratch\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestADockerfileInTheModulesOwnDirectoryPasses(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
module(t, root, "own", "Dockerfile", "Dockerfile")
|
||||
module(t, root, "nested", "image/Dockerfile", "image/Dockerfile")
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("found %v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADockerfileOutsideTheModulesDirectoryFails(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
module(t, root, "other", "Dockerfile", "Dockerfile")
|
||||
module(t, root, "leaves", "../other/Dockerfile")
|
||||
module(t, root, "absolute", "/etc/Dockerfile")
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var mods []string
|
||||
for _, f := range got {
|
||||
mods = append(mods, f.Module)
|
||||
}
|
||||
if want := []string{"absolute", "leaves"}; !reflect.DeepEqual(mods, want) {
|
||||
t.Errorf("refused %v (%v), want %v", mods, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMissingDockerfileFails(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
module(t, root, "gone", "Dockerfile")
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Module != "gone" {
|
||||
t.Errorf("found %v, want gone refused", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue as it stands: this is the check itself, run here too.
|
||||
func TestTheCataloguesImagesBuildFromTheirOwnDirectories(t *testing.T) {
|
||||
got, err := Check("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("an image builds from outside its module: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/locks
|
||||
|
||||
go 1.22
|
||||
@@ -1,105 +0,0 @@
|
||||
// Command locks refuses a TypeScript module that names packages beyond the SDK in its package.json and commits no
|
||||
// package-lock.json (novox/hq ADR 0469 §5, issue 1868): the Node build module installs a module's own packages only
|
||||
// from its committed lock, so such a module is refused by the builder and could not be delivered.
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding. The packages that count are those the build module counts (mesh-build-node fetch.go
|
||||
// ownDependencies): the `dependencies` beyond the SDK, typescript and the @types packages.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Finding is one module refused: it names Packages beyond the SDK and has no lock file.
|
||||
type Finding struct {
|
||||
Module string
|
||||
Packages []string
|
||||
}
|
||||
|
||||
// Check reads every module under root/modules that builds a TypeScript artifact.
|
||||
func Check(root string) ([]Finding, error) {
|
||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(manifests)
|
||||
var out []Finding
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Build struct {
|
||||
Artifacts []struct {
|
||||
Language string `json:"language"`
|
||||
} `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
typescript := false
|
||||
for _, a := range m.Build.Artifacts {
|
||||
typescript = typescript || a.Language == "typescript"
|
||||
}
|
||||
if !typescript {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
pkgRaw, err := os.ReadFile(filepath.Join(dir, "package.json"))
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var pkg struct {
|
||||
Dependencies map[string]json.RawMessage `json:"dependencies"`
|
||||
}
|
||||
if err := json.Unmarshal(pkgRaw, &pkg); err != nil {
|
||||
return nil, fmt.Errorf("%s: package.json: %w", m.Module, err)
|
||||
}
|
||||
var names []string
|
||||
for n := range pkg.Dependencies {
|
||||
if n != "@novox/mesh-sdk" && n != "typescript" && !strings.HasPrefix(n, "@types/") {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "package-lock.json")); err != nil {
|
||||
sort.Strings(names)
|
||||
out = append(out, Finding{Module: m.Module, Packages: names})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
root := "."
|
||||
if len(os.Args) > 1 {
|
||||
root = os.Args[1]
|
||||
}
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, f := range found {
|
||||
fmt.Printf("%s names packages beyond the SDK (%s) and has no package-lock.json: commit one made by the build module's toolchain (novox/hq ADR 0469 §5)\n", f.Module, strings.Join(f.Packages, ", "))
|
||||
}
|
||||
if len(found) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("locks: every TypeScript module that names packages beyond the SDK commits its lock")
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// module writes modules/<name> with a TypeScript artifact, the package.json given ("" for none) and, when lock, a lock.
|
||||
func module(t *testing.T, root, name, language, pkg string, lock bool) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, "modules", name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write := func(file, body string) {
|
||||
if err := os.WriteFile(filepath.Join(dir, file), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("module.json", `{"module":"`+name+`","build":{"artifacts":[{"name":"tools","kind":"bundle","language":"`+language+`","from":"index.ts"}]}}`)
|
||||
if pkg != "" {
|
||||
write("package.json", pkg)
|
||||
}
|
||||
if lock {
|
||||
write("package-lock.json", "{}")
|
||||
}
|
||||
}
|
||||
|
||||
const sdkOnly = `{"dependencies":{"@novox/mesh-sdk":"^0.1.1"},"devDependencies":{"typescript":"^5","@types/node":"^22"}}`
|
||||
const withPg = `{"dependencies":{"@novox/mesh-sdk":"^0.1.1","pg":"^8","@types/pg":"^8"}}`
|
||||
|
||||
func TestAModuleNamingOnlyTheSDKNeedsNoLock(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
module(t, root, "plain", "typescript", sdkOnly, false)
|
||||
module(t, root, "none", "typescript", "", false)
|
||||
got, err := Check(root)
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Fatalf("%v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleNamingMorePackagesWithoutALockIsRefusedNamingThem(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
module(t, root, "db", "typescript", withPg, false)
|
||||
module(t, root, "db-locked", "typescript", withPg, true)
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []Finding{{Module: "db", Packages: []string{"pg"}}}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleThatIsNotTypeScriptIsNotJudged(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
module(t, root, "go", "go", withPg, false)
|
||||
if got, err := Check(root); err != nil || len(got) != 0 {
|
||||
t.Fatalf("%v %v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/mailstore
|
||||
|
||||
go 1.22
|
||||
@@ -1,81 +0,0 @@
|
||||
// Command mailstore refuses any manifest but modules/mesh-mail claiming the mail-store seat (novox/hq ADR 0378
|
||||
// §3, issue 800 part A): the operator's mail is read, searched and sent through this one seat, and a second
|
||||
// claimant would be a second path to the same mail that ADR 0378 never accounted for.
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// MailStoreSeat is the seat only mesh-mail may claim.
|
||||
const MailStoreSeat = "mail-store"
|
||||
|
||||
// MailStoreModule is the one module allowed to claim it.
|
||||
const MailStoreModule = "mesh-mail"
|
||||
|
||||
// Finding is one manifest refused: a module other than mesh-mail claiming mail-store.
|
||||
type Finding struct {
|
||||
Module string
|
||||
}
|
||||
|
||||
// Check reads every manifest under root/modules and answers which ones it refuses.
|
||||
func Check(root string) ([]Finding, error) {
|
||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(manifests)
|
||||
var out []Finding
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Claims []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"claims"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
if m.Module == MailStoreModule {
|
||||
continue
|
||||
}
|
||||
for _, c := range m.Claims {
|
||||
if c.Name == MailStoreSeat {
|
||||
out = append(out, Finding{Module: m.Module})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
root := "."
|
||||
if len(os.Args) > 1 {
|
||||
root = os.Args[1]
|
||||
}
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, f := range found {
|
||||
fmt.Printf("%s claims the mail-store seat: only %s may (novox/hq ADR 0378 §3)\n", f.Module, MailStoreModule)
|
||||
}
|
||||
if len(found) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("mail-store: claimed only by mesh-mail")
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func manifest(t *testing.T, root, module, claims string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, "modules", module)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := `{"module":"` + module + `","claims":[` + claims + `]}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnotherModuleClaimingMailStoreIsRefused(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "mesh-mail", `{"name":"mail-store","scope":"mesh"}`)
|
||||
manifest(t, root, "betterbird", `{"name":"mail-store","scope":"mesh"}`)
|
||||
manifest(t, root, "dunst", `{"name":"node-notifier","scope":"node"}`)
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 1 || found[0].Module != "betterbird" {
|
||||
t.Fatalf("refused %+v", found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCatalogueHasNone(t *testing.T) {
|
||||
found, err := Check("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 0 {
|
||||
t.Fatalf("%+v", found)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/owned
|
||||
|
||||
go 1.22
|
||||
@@ -1,242 +0,0 @@
|
||||
// Command owned judges how the catalogue's directories and archives treat files they did not write (novox/hq
|
||||
// ADR 0422 §8, issue 1024). It refuses, naming the module, the resource and the reason:
|
||||
//
|
||||
// 1. a directory or archive without `unknown`, or with a value but move-out, module or program;
|
||||
//
|
||||
// 2. `move-out` on a path not under ${machine:account-home} or ${machine:agent-home};
|
||||
//
|
||||
// 3. `knows` with an empty, absolute, `.`/`..`/empty-component or repeated path;
|
||||
//
|
||||
// 4. a path one module declares (a resource's path, or a fact with "home": true) lying inside a directory of
|
||||
// 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>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Home is the one root both machine homes are read as.
|
||||
const Home = "${home}"
|
||||
|
||||
var (
|
||||
ofDir = regexp.MustCompile(`\$\{dir:([a-z0-9][a-z0-9_-]*)\}`)
|
||||
homeVar = strings.NewReplacer("${machine:account-home}", Home, "${machine:agent-home}", Home)
|
||||
values = map[string]bool{"move-out": true, "module": true, "program": true}
|
||||
)
|
||||
|
||||
// Finding is one refusal.
|
||||
type Finding struct {
|
||||
Rule int
|
||||
Module, ID, Why string
|
||||
}
|
||||
|
||||
type resource struct {
|
||||
ID, Type, Path, Unknown string
|
||||
Knows []string
|
||||
HasKnows bool
|
||||
}
|
||||
|
||||
type parsed struct {
|
||||
Module string
|
||||
Resources []resource
|
||||
Facts []string // paths of facts with "home": true, under the home
|
||||
}
|
||||
|
||||
func load(file string) (*parsed, error) {
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Resources []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
Unknown *string `json:"unknown"`
|
||||
Knows []string `json:"knows"`
|
||||
} `json:"resources"`
|
||||
Facts map[string]struct {
|
||||
Path string `json:"path"`
|
||||
Home bool `json:"home"`
|
||||
} `json:"facts"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", file, err)
|
||||
}
|
||||
out := &parsed{Module: m.Module}
|
||||
if out.Module == "" {
|
||||
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}
|
||||
if r.Unknown != nil {
|
||||
x.Unknown = *r.Unknown
|
||||
if x.Unknown == "" {
|
||||
x.Unknown = "(empty)"
|
||||
}
|
||||
}
|
||||
out.Resources = append(out.Resources, x)
|
||||
}
|
||||
keys := make([]string, 0, len(m.Facts))
|
||||
for k := range m.Facts {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
if f := m.Facts[k]; f.Home && f.Path != "" {
|
||||
out.Facts = append(out.Facts, f.Path)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// norm resolves ${dir:<id>} from the module's own resources, reads both homes as one root and cleans the path.
|
||||
func norm(p string, dirs map[string]string) string {
|
||||
for i := 0; i < 8 && ofDir.MatchString(p); i++ {
|
||||
p = ofDir.ReplaceAllStringFunc(p, func(w string) string {
|
||||
if to, ok := dirs[ofDir.FindStringSubmatch(w)[1]]; ok {
|
||||
return to
|
||||
}
|
||||
return w
|
||||
})
|
||||
}
|
||||
p = homeVar.Replace(p)
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
return path.Clean(p)
|
||||
}
|
||||
|
||||
func underHome(p string) bool { return strings.HasPrefix(p, Home+"/") }
|
||||
|
||||
// badKnows says why a knows entry is refused, or "".
|
||||
func badKnows(k string) string {
|
||||
if k == "" {
|
||||
return "is empty"
|
||||
}
|
||||
if strings.HasPrefix(k, "/") {
|
||||
return "is absolute"
|
||||
}
|
||||
for _, c := range strings.Split(strings.TrimSuffix(k, "/"), "/") {
|
||||
if c == "" || c == "." || c == ".." {
|
||||
return "has an empty, . or .. component"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type declared struct {
|
||||
module, id, path string
|
||||
}
|
||||
|
||||
// Check reads every manifest under root/modules and answers what it refuses.
|
||||
func Check(root string) ([]Finding, error) {
|
||||
files, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(files)
|
||||
var out []Finding
|
||||
var paths []declared
|
||||
type container struct{ module, path string }
|
||||
var containers []container
|
||||
for _, file := range files {
|
||||
m, err := load(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dirs := map[string]string{}
|
||||
for _, r := range m.Resources {
|
||||
switch {
|
||||
case r.ID != "" && r.Path != "":
|
||||
dirs[r.ID] = r.Path
|
||||
case r.ID != "" && r.Type == "directory":
|
||||
// A directory the mesh places for the module (`place`): where it lies is the node-engine's, but it is
|
||||
// the module's own, so a path under it belongs to this module and to no other's directory of the same
|
||||
// id (issue 1484: two runs-as modules each keep a .ssh in their own state directory).
|
||||
dirs[r.ID] = "/placed/" + m.Module + "/" + r.ID
|
||||
}
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
p := norm(r.Path, dirs)
|
||||
if p != "" {
|
||||
paths = append(paths, declared{m.Module, r.ID, p})
|
||||
}
|
||||
if r.Type != "directory" && r.Type != "archive" {
|
||||
continue
|
||||
}
|
||||
if r.Type == "directory" && p != "" {
|
||||
containers = append(containers, container{m.Module, p})
|
||||
}
|
||||
add := func(rule int, why string) { out = append(out, Finding{rule, m.Module, r.ID, why}) }
|
||||
switch {
|
||||
case r.Unknown == "":
|
||||
add(1, "a "+r.Type+" without `unknown` (move-out, module or program)")
|
||||
case !values[r.Unknown]:
|
||||
add(1, fmt.Sprintf("`unknown` is %q, not move-out, module or program", r.Unknown))
|
||||
case r.Unknown == "move-out" && !underHome(p):
|
||||
add(2, fmt.Sprintf("`move-out` on %q, which is not under ${machine:account-home} or ${machine:agent-home}", r.Path))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, k := range r.Knows {
|
||||
if why := badKnows(k); why != "" {
|
||||
add(3, fmt.Sprintf("`knows` entry %q %s", k, why))
|
||||
continue
|
||||
}
|
||||
c := strings.TrimSuffix(k, "/")
|
||||
if seen[c] {
|
||||
add(3, fmt.Sprintf("`knows` entry %q is repeated", k))
|
||||
}
|
||||
seen[c] = true
|
||||
}
|
||||
if r.HasKnows && len(r.Knows) == 0 {
|
||||
add(3, "`knows` is empty")
|
||||
}
|
||||
}
|
||||
for _, f := range m.Facts {
|
||||
if p := norm(Home+"/"+f, nil); p != "" {
|
||||
paths = append(paths, declared{m.Module, "fact " + f, p})
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, d := range paths {
|
||||
for _, c := range containers {
|
||||
if c.module != d.module && (d.path == c.path || strings.HasPrefix(d.path, c.path+"/")) {
|
||||
out = append(out, Finding{4, d.module, d.id, fmt.Sprintf("declares %q, inside a directory of %s (%q)", d.path, c.module, c.path)})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
root := "."
|
||||
if len(os.Args) > 1 {
|
||||
root = os.Args[1]
|
||||
}
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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")
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func manifest(t *testing.T, root, module, body string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, "modules", module)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func check(t *testing.T, root string) []Finding {
|
||||
t.Helper()
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// only asserts exactly one finding, of the rule, naming the module and the resource (issue 1024).
|
||||
func only(t *testing.T, found []Finding, rule int, module, id string) {
|
||||
t.Helper()
|
||||
if len(found) != 1 || found[0].Rule != rule || found[0].Module != module || found[0].ID != id || found[0].Why == "" {
|
||||
t.Fatalf("found %+v; want one finding of rule %d for %s/%s with a reason", found, rule, module, id)
|
||||
}
|
||||
}
|
||||
|
||||
func dir(id, path, rest string) string {
|
||||
return `{"id":"` + id + `","type":"directory","path":"` + path + `"` + rest + `}`
|
||||
}
|
||||
|
||||
func mod(name string, resources ...string) string {
|
||||
return `{"module":"` + name + `","resources":[` + strings.Join(resources, ",") + `]}`
|
||||
}
|
||||
|
||||
func TestMissingOrUnknownValueIsRefused(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "a", mod("a", dir("d", "/var/lib/a", "")))
|
||||
only(t, check(t, root), 1, "a", "d")
|
||||
|
||||
root = t.TempDir()
|
||||
manifest(t, root, "a", mod("a", `{"id":"z","type":"archive","path":"/opt/a","unknown":"delete"}`))
|
||||
only(t, check(t, root), 1, "a", "z")
|
||||
}
|
||||
|
||||
func TestMoveOutOutsideAHomeIsRefused(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "a", mod("a", dir("d", "/etc/a.d", `,"unknown":"move-out"`)))
|
||||
only(t, check(t, root), 2, "a", "d")
|
||||
}
|
||||
|
||||
func TestMoveOutUnderAHomeIsAllowed(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "a", mod("a",
|
||||
dir("d", "${machine:account-home}/.config/a", `,"unknown":"move-out"`),
|
||||
dir("e", "${dir:d}/sub", `,"unknown":"move-out"`),
|
||||
dir("f", "${machine:agent-home}/x", `,"unknown":"move-out"`),
|
||||
dir("g", "/var/lib/a", `,"unknown":"program"`),
|
||||
dir("h", "${machine:account-home}/.ssh", `,"unknown":"module"`)))
|
||||
if found := check(t, root); len(found) != 0 {
|
||||
t.Fatalf("refused %+v", found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadKnowsIsRefused(t *testing.T) {
|
||||
for _, knows := range []string{`[""]`, `["/abs"]`, `["a/../b"]`, `["./a"]`, `["a//b"]`, `["a","a"]`, `["a","a/"]`} {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "a", mod("a", dir("d", "${machine:account-home}/.config/a", `,"unknown":"move-out","knows":`+knows)))
|
||||
only(t, check(t, root), 3, "a", "d")
|
||||
}
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "a", mod("a", dir("d", "${machine:account-home}/.config/a", `,"unknown":"move-out","knows":["forge.credentials","kept/"]`)))
|
||||
if found := check(t, root); len(found) != 0 {
|
||||
t.Fatalf("refused %+v", found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnotherModulesPathInsideADirectoryIsRefused(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "owner", mod("owner", dir("ssh", "${machine:account-home}/.ssh", `,"unknown":"module"`)))
|
||||
manifest(t, root, "intruder", mod("intruder", `{"id":"f","type":"file","path":"${machine:account-home}/.ssh/config.d/20"}`))
|
||||
manifest(t, root, "viahome", mod("viahome", `{"id":"g","type":"file","path":"${machine:agent-home}/.ssh/x"}`))
|
||||
manifest(t, root, "facty", `{"module":"facty","facts":{"hosts":{"path":".ssh/config.d/00","home":true}}}`)
|
||||
manifest(t, root, "outside", mod("outside", `{"id":"o","type":"file","path":"${machine:account-home}/.sshrc"}`))
|
||||
got := map[string]int{}
|
||||
for _, f := range check(t, root) {
|
||||
if f.Rule != 4 || f.Why == "" {
|
||||
t.Fatalf("finding %+v", f)
|
||||
}
|
||||
got[f.Module]++
|
||||
}
|
||||
if len(got) != 3 || got["intruder"] != 1 || got["viahome"] != 1 || got["facty"] != 1 {
|
||||
t.Fatalf("refused %v; want intruder, viahome, facty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirReferencesAreResolvedPerModule(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "owner", mod("owner", dir("base", "${machine:account-home}/.config/o", `,"unknown":"move-out"`)))
|
||||
manifest(t, root, "other", mod("other",
|
||||
dir("base", "${machine:account-home}/.config/p", `,"unknown":"move-out"`),
|
||||
`{"id":"f","type":"file","path":"${dir:base}/x"}`))
|
||||
if found := check(t, root); len(found) != 0 {
|
||||
t.Fatalf("refused %+v", found)
|
||||
}
|
||||
manifest(t, root, "third", mod("third",
|
||||
dir("d", "${machine:account-home}/.config/t", `,"unknown":"move-out"`),
|
||||
`{"id":"f","type":"file","path":"${dir:d}/../o/x"}`))
|
||||
only(t, check(t, root), 4, "third", "f")
|
||||
}
|
||||
|
||||
func TestAnArchiveIsNotAContainerAndSameModuleIsFine(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "a", mod("a",
|
||||
`{"id":"arc","type":"archive","path":"/opt/a","unknown":"program"}`,
|
||||
dir("d", "/opt/a2", `,"unknown":"program"`),
|
||||
`{"id":"f","type":"file","path":"/opt/a2/x"}`))
|
||||
manifest(t, root, "b", mod("b", `{"id":"f","type":"file","path":"/opt/a/x"}`))
|
||||
if found := check(t, root); len(found) != 0 {
|
||||
t.Fatalf("refused %+v", found)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1484: a directory the mesh places for a module (no path, `place`) is that module's own: another module's
|
||||
// directory of the same id, and a path under it, are not inside it.
|
||||
func TestPlacedDirectoriesOfTwoModulesAreTheirOwn(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, name := range []string{"first", "second"} {
|
||||
manifest(t, root, name, mod(name,
|
||||
`{"id":"state","type":"directory","place":".","unknown":"program"}`,
|
||||
dir("ssh", "${dir:state}/.ssh", `,"unknown":"program"`),
|
||||
`{"id":"conf","type":"file","path":"${dir:ssh}/config"}`))
|
||||
}
|
||||
if found := check(t, root); len(found) != 0 {
|
||||
t.Fatalf("refused %+v", found)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# The modules the check covers (novox/hq ADR 0507 §9, issue 1838), one a line; issue 1838 widens it.
|
||||
pacman
|
||||
kernel
|
||||
@@ -1,3 +0,0 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/particulars
|
||||
|
||||
go 1.22
|
||||
@@ -1,159 +0,0 @@
|
||||
// Command particulars keeps this mesh's node names out of the modules it covers (novox/hq ADR 0507 §9,
|
||||
// issue 1838): a module is for any mesh, so no manifest, file, code string, identifier, comment, test name or
|
||||
// fixture of a covered module names a node of this one.
|
||||
//
|
||||
// The names are in the file `particulars` at the catalogue's root, one a line, `#` comments — the one place
|
||||
// they may be written. The modules covered are in `covered` at checks/particulars/covered, one a line. A name is matched
|
||||
// whole and in any case; a hyphen, an underscore or a CamelCase boundary parts one, so a longer word holding a name is another word.
|
||||
// The forge is not a node name: its host (git.novox.be) and the organisation path (novox/<repo>) are blanked
|
||||
// before a line is read.
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// Exits 1 naming file:line and the word, 2 when a list cannot be read.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// forge is the forge's host and organisation path, blanked before a line is read; a path segment novox/ elsewhere
|
||||
// (testdata/novox/x, /srv/novox/data) is not blanked.
|
||||
var forge = regexp.MustCompile(`(?i)git\.novox\.be(?:/novox/[\w.-]+)?|(?:^|[^/\w.-])novox/[\w.-]+`)
|
||||
|
||||
func main() {
|
||||
root := "."
|
||||
if len(os.Args) > 1 {
|
||||
root = os.Args[1]
|
||||
}
|
||||
os.Exit(check(root))
|
||||
}
|
||||
|
||||
// check runs the check over a catalogue and returns the exit code: 0 clean, 1 a finding, 2 a list unreadable.
|
||||
func check(root string) int {
|
||||
names, err := readList(filepath.Join(root, "particulars"))
|
||||
if err == nil && len(names) == 0 {
|
||||
err = fmt.Errorf("particulars lists no name — the check would pass on anything")
|
||||
}
|
||||
var covered []string
|
||||
if err == nil {
|
||||
covered, err = readList(filepath.Join(root, "checks/particulars/covered"))
|
||||
if err == nil && len(covered) == 0 {
|
||||
err = fmt.Errorf("covered lists no module — the check would pass on anything")
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "particulars:", err)
|
||||
return 2
|
||||
}
|
||||
findings, err := run(root, names, covered)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "particulars:", err)
|
||||
return 2
|
||||
}
|
||||
for _, f := range findings {
|
||||
fmt.Println(f)
|
||||
}
|
||||
if len(findings) > 0 {
|
||||
fmt.Printf("particulars: %d node name(s) in a module for any mesh (novox/hq ADR 0507 §9) — say what it shows, not where it was seen\n", len(findings))
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("particulars: none of %d node names in the %d covered modules\n", len(names), len(covered))
|
||||
return 0
|
||||
}
|
||||
|
||||
// readList reads a list: one entry a line; blank lines and lines starting with # are not entries.
|
||||
func readList(path string) ([]string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
var out []string
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return out, sc.Err()
|
||||
}
|
||||
|
||||
// run reads every text file of each covered module and returns the findings, sorted.
|
||||
func run(root string, names, covered []string) ([]string, error) {
|
||||
var out []string
|
||||
for _, m := range covered {
|
||||
dir := filepath.Join(root, "modules", m)
|
||||
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
switch d.Name() {
|
||||
case "node_modules", "dist", "vendor", ".git":
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
// A path is read too: a testdata directory named after a node is a finding.
|
||||
for _, n := range hits(filepath.ToSlash(rel), names) {
|
||||
out = append(out, fmt.Sprintf("%s:0: node name %q in the path", filepath.ToSlash(rel), n))
|
||||
}
|
||||
if d.Name() == "go.sum" {
|
||||
return nil
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if bytes.IndexByte(b, 0) >= 0 {
|
||||
return nil
|
||||
}
|
||||
out = append(out, scan(filepath.ToSlash(rel), string(b), names)...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// scan returns a finding for each node name in src, whole-word and in any case.
|
||||
func scan(file, src string, names []string) []string {
|
||||
var out []string
|
||||
for i, line := range strings.Split(src, "\n") {
|
||||
for _, n := range hits(line, names) {
|
||||
out = append(out, fmt.Sprintf("%s:%d: node name %q — %s", file, i+1, n, strings.Join(strings.Fields(line), " ")))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hits returns the node names one line holds: as a word (a hyphen or underscore parts it), as a part of a
|
||||
// CamelCase identifier (TestAce, novoxTree), the forge's host and organisation path blanked first.
|
||||
func hits(line string, names []string) []string {
|
||||
line = forge.ReplaceAllStringFunc(line, func(s string) string { return strings.Repeat(" ", len(s)) })
|
||||
var out []string
|
||||
for _, n := range names {
|
||||
q := regexp.QuoteMeta(n)
|
||||
camel := regexp.QuoteMeta(strings.ToUpper(n[:1]) + n[1:])
|
||||
rx := regexp.MustCompile(`(?i:(?:^|[^A-Za-z0-9])` + q + `(?:$|[^A-Za-z0-9]))|[a-z0-9]` + camel + `(?:$|[^a-z])|(?:^|[^A-Za-z0-9])` + q + `[A-Z0-9]`)
|
||||
if rx.MatchString(line) {
|
||||
out = append(out, strings.ToLower(n))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var names = []string{"novox", "ace", "shanks", "g14"}
|
||||
|
||||
func TestANodeNameIsRefusedWhereItIsWritten(t *testing.T) {
|
||||
cases := map[string]struct{ file, src string }{
|
||||
"manifest string": {"modules/kernel/module.json", "{\n \"description\": \"as on Shanks\"\n}\n"},
|
||||
"file line": {"modules/kernel/files/boot.sh", "#!/bin/sh\necho run on g14\n"},
|
||||
"go identifier": {"modules/kernel/cmd/x/a.go", "package main\n\nfunc novox() {}\n"},
|
||||
"test name": {"modules/kernel/cmd/x/a_test.go", "package main\n\nfunc TestAce(t *testing.T) {}\n\nfunc TestOnAce_x(t *testing.T) {}\n"},
|
||||
}
|
||||
for name, c := range cases {
|
||||
if got := scan(c.file, c.src, names); len(got) == 0 {
|
||||
t.Errorf("%s: %q not refused", name, c.src)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheFindingNamesTheLineAndTheWord(t *testing.T) {
|
||||
got := scan("f.sh", "a\nb\nrun on ACE now\n", names)
|
||||
if len(got) != 1 || !strings.HasPrefix(got[0], "f.sh:3:") || !strings.Contains(got[0], "ace") {
|
||||
t.Errorf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheForgeIsNotANodeName(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"see novox/hq issue 5",
|
||||
"module git.novox.be/novox/mesh-sdk/go v1.2.3",
|
||||
"https://git.novox.be/novox/mesh-catalog",
|
||||
"git.novox.be",
|
||||
"space trace surface grace g14x",
|
||||
} {
|
||||
if got := scan("f", s, names); len(got) != 0 {
|
||||
t.Errorf("%q refused: %v", s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodeNameInAPathOrJoinedIsRefused(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"modules/kernel/testdata/novox/meminfo",
|
||||
"/srv/novox/data",
|
||||
"testdata/shanks-meminfo.txt",
|
||||
"on-shanks",
|
||||
"shanks-desktop",
|
||||
"mesh-novox",
|
||||
"func novoxTree() {}",
|
||||
"aceFiles := 1",
|
||||
"g14Meminfo := 2",
|
||||
"novox_tree := 3",
|
||||
} {
|
||||
if got := scan("f", s, names); len(got) == 0 {
|
||||
t.Errorf("%q not refused", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodeNamedPathIsRefusedThroughRun(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
p := filepath.Join(root, "modules/kernel/testdata/ace/x")
|
||||
os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
os.WriteFile(p, []byte("clean\n"), 0o644)
|
||||
got, err := run(root, names, []string{"kernel"})
|
||||
if err != nil || len(got) != 1 || !strings.HasPrefix(got[0], "modules/kernel/testdata/ace/x:0: ") {
|
||||
t.Errorf("got %v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckReturnsOneOnAFindingAndTwoOnAnUnreadableList(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
write := func(p, s string) {
|
||||
p = filepath.Join(root, p)
|
||||
os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
os.WriteFile(p, []byte(s), 0o644)
|
||||
}
|
||||
write("particulars", "ace\n")
|
||||
write("checks/particulars/covered", "kernel\n")
|
||||
write("modules/kernel/a", "clean\n")
|
||||
if got := check(root); got != 0 {
|
||||
t.Errorf("clean = %d, want 0", got)
|
||||
}
|
||||
write("modules/kernel/a", "on ace\n")
|
||||
if got := check(root); got != 1 {
|
||||
t.Errorf("finding = %d, want 1", got)
|
||||
}
|
||||
os.Remove(filepath.Join(root, "particulars"))
|
||||
if got := check(root); got != 2 {
|
||||
t.Errorf("no list = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyCoveredModulesAreRead(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
write := func(p, s string) {
|
||||
p = filepath.Join(root, p)
|
||||
os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
os.WriteFile(p, []byte(s), 0o644)
|
||||
}
|
||||
write("modules/kernel/files/a", "on novox\n")
|
||||
write("modules/other/files/a", "on novox\n")
|
||||
got, err := run(root, names, []string{"kernel"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || !strings.Contains(got[0], "modules/kernel/files/a:1") {
|
||||
t.Errorf("got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/predefined
|
||||
|
||||
go 1.22
|
||||
@@ -1,153 +0,0 @@
|
||||
// Command predefined refuses a change to predefined.json that removes a key a module still names (novox/hq ADR
|
||||
// 0348 clause 7, design 57 package K2, issue 661).
|
||||
//
|
||||
// Mesh config and node config hold values only for the keys on the catalogue's list of predefined keys, at the
|
||||
// root of the repository; a module's config value may fall back to one, `"predefined": "<key>"`. A key removed
|
||||
// while a manifest still names it would leave that module falling back to nothing: the controller refuses its
|
||||
// build then, at the next merge that touches it. So the removal is refused here, in the pull request that makes
|
||||
// it, naming each module that names the key.
|
||||
//
|
||||
// **What it cannot know.** Whether a key still holds a value at the mesh or a node is mesh-config's to say (design
|
||||
// 57 package K3): it holds the values, and the catalogue's check reads no store. Until mesh-config refuses that
|
||||
// itself, removing a key nobody names is let through here, and said.
|
||||
//
|
||||
// The list it compares with is the trunk's at the merge base, given with -base; without one, nothing was removed.
|
||||
// The controller the mesh runs reads the list itself (type, validation, every manifest against it) in the
|
||||
// module check; this check reads only its keys.
|
||||
//
|
||||
// go run . [-base <the trunk's predefined.json>] <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// File is the list's file at the root of the catalogue.
|
||||
const File = "predefined.json"
|
||||
|
||||
// Keys is the keys of a list, read as a JSON object; an absent file has none.
|
||||
func Keys(path string) (map[string]bool, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return map[string]bool{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var keys map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &keys); err != nil {
|
||||
return nil, fmt.Errorf("%s is not a JSON object of keys: %w", path, err)
|
||||
}
|
||||
out := map[string]bool{}
|
||||
for k := range keys {
|
||||
out[k] = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Named is, per predefined key, the modules whose manifests name it: under `config`, or its alias `settings`.
|
||||
func Named(root string) (map[string][]string, error) {
|
||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(manifests)
|
||||
named := map[string][]string{}
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Config map[string]json.RawMessage `json:"config"`
|
||||
Settings map[string]json.RawMessage `json:"settings"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
for _, declared := range []map[string]json.RawMessage{m.Config, m.Settings} {
|
||||
for _, d := range declared {
|
||||
var value struct {
|
||||
Predefined string `json:"predefined"`
|
||||
}
|
||||
if json.Unmarshal(d, &value) == nil && value.Predefined != "" {
|
||||
named[value.Predefined] = append(named[value.Predefined], m.Module)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return named, nil
|
||||
}
|
||||
|
||||
// Removed is each key the base list holds and the tree's does not, with the modules still naming it; a removed
|
||||
// key nobody names is answered with none.
|
||||
func Removed(base, tree map[string]bool, named map[string][]string) map[string][]string {
|
||||
out := map[string][]string{}
|
||||
for k := range base {
|
||||
if !tree[k] {
|
||||
out[k] = named[k]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func main() {
|
||||
basePath := flag.String("base", "", "the trunk's predefined.json at the merge base")
|
||||
flag.Parse()
|
||||
if flag.NArg() != 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: predefined [-base <the trunk's predefined.json>] <catalogue root>")
|
||||
os.Exit(2)
|
||||
}
|
||||
root := flag.Arg(0)
|
||||
tree, err := Keys(filepath.Join(root, File))
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if *basePath == "" {
|
||||
fmt.Printf("%s: %d key(s); no trunk list given, so none was removed\n", File, len(tree))
|
||||
return
|
||||
}
|
||||
base, err := Keys(*basePath)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
named, err := Named(root)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
removed := Removed(base, tree, named)
|
||||
keys := make([]string, 0, len(removed))
|
||||
for k := range removed {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
failed := false
|
||||
for _, k := range keys {
|
||||
if modules := removed[k]; len(modules) > 0 {
|
||||
failed = true
|
||||
fmt.Printf("%s: the key %q is removed and still named by %s: move each off it first (novox/hq ADR 0348 "+
|
||||
"clause 7)\n", File, k, strings.Join(modules, ", "))
|
||||
} else {
|
||||
fmt.Printf("%s: the key %q is removed and no module names it; whether the mesh or a node still holds a "+
|
||||
"value for it is mesh-config's to refuse, not this check's\n", File, k)
|
||||
}
|
||||
}
|
||||
if failed {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("%s: %d key(s), none removed that a module names\n", File, len(tree))
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A catalogue whose dunst falls back to font-size, and the trunk's list holding font-size and
|
||||
// agent-managed-settings.
|
||||
func aCatalogue(t *testing.T, list string) (root, base string) {
|
||||
t.Helper()
|
||||
root = t.TempDir()
|
||||
write := func(path, text string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write(filepath.Join(root, "modules", "dunst", "module.json"), `{"module": "dunst", "config": {"font-size":
|
||||
{"type": "number", "description": "points", "default": 10, "predefined": "font-size"}}}`)
|
||||
write(filepath.Join(root, "modules", "claude-code", "module.json"), `{"module": "claude-code", "settings": {
|
||||
"managed_settings": {"kind": "preference", "default": "{}", "why": "none"}}}`)
|
||||
write(filepath.Join(root, "modules", "plain", "module.json"), `{"module": "plain"}`)
|
||||
if list != "" {
|
||||
write(filepath.Join(root, File), list)
|
||||
}
|
||||
base = filepath.Join(t.TempDir(), "base.json")
|
||||
write(base, `{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}}`)
|
||||
return root, base
|
||||
}
|
||||
|
||||
func run(t *testing.T, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
out, err := exec.Command("go", append([]string{"run", "."}, args...)...).CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func TestARemovalOfAKeyAModuleStillNamesIsRefused(t *testing.T) {
|
||||
// The planted removal: font-size goes while dunst still falls back to it.
|
||||
root, base := aCatalogue(t, `{"agent-managed-settings": {"type": "object"}}`)
|
||||
out, err := run(t, "-base", base, root)
|
||||
if err == nil || !strings.Contains(out, `the key "font-size" is removed and still named by dunst`) {
|
||||
t.Fatalf("a removal of a named key passed:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARemovalNobodyNamesPassesAndSaysWhatItCannotKnow(t *testing.T) {
|
||||
root, base := aCatalogue(t, `{"font-size": {"type": "number"}}`)
|
||||
out, err := run(t, "-base", base, root)
|
||||
if err != nil || !strings.Contains(out, `"agent-managed-settings" is removed and no module names it`) ||
|
||||
!strings.Contains(out, "mesh-config's to refuse") {
|
||||
t.Fatalf("a removal nobody names: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAListUnchangedOrGrownPasses(t *testing.T) {
|
||||
for _, list := range []string{
|
||||
`{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}}`,
|
||||
`{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}, "accent": {"type": "string"}}`,
|
||||
} {
|
||||
root, base := aCatalogue(t, list)
|
||||
if out, err := run(t, "-base", base, root); err != nil || !strings.Contains(out, "none removed") {
|
||||
t.Fatalf("%s: %v\n%s", list, err, out)
|
||||
}
|
||||
}
|
||||
// No trunk list given: nothing was removed.
|
||||
root, _ := aCatalogue(t, `{}`)
|
||||
if out, err := run(t, root); err != nil || !strings.Contains(out, "none was removed") {
|
||||
t.Fatalf("without a base: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheFileRemovedWholeIsARemovalOfEveryKey(t *testing.T) {
|
||||
root, base := aCatalogue(t, "")
|
||||
out, err := run(t, "-base", base, root)
|
||||
if err == nil || !strings.Contains(out, `"font-size" is removed and still named by dunst`) {
|
||||
t.Fatalf("the list removed whole passed:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamedReadsBothForms(t *testing.T) {
|
||||
root, _ := aCatalogue(t, "")
|
||||
if err := os.WriteFile(filepath.Join(root, "modules", "plain", "module.json"), []byte(`{"module": "plain",
|
||||
"settings": {"size": {"predefined": "font-size"}}}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
named, err := Named(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Join(named["font-size"], ","); got != "dunst,plain" {
|
||||
t.Fatalf("font-size named by %s", got)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/secrets
|
||||
|
||||
go 1.22
|
||||
@@ -1,4 +0,0 @@
|
||||
# Secrets nested below a key a mergeable file takes for its other values, which the controller cannot
|
||||
# protect alone yet (it protects top-level keys): "<module> <resource> <dotted key>". This list only shrinks:
|
||||
# a line that no longer names a finding is refused, and so is one the trunk's copy does not hold (hq issue 342).
|
||||
searxng settings server.secret_key
|
||||
@@ -1,254 +0,0 @@
|
||||
// Command secrets refuses, in every module's manifest, a mergeable file that a setting could fill with a
|
||||
// password, a token or an API key in place of the vault's (novox/hq issue 342).
|
||||
//
|
||||
// A password is not a setting: it comes from the key vault, and it can be rotated there. A file that says
|
||||
// `"merge": "json"` takes the keys of the mesh-wide and the node's layers over its own content, so a key the
|
||||
// content fills with `${secret:<name>}` is one a layer could replace, and the vault's value would lose to it
|
||||
// until somebody removed it by hand. The controller refuses a setting of a key the file keeps as `protected`,
|
||||
// naming the layer and the key, never the value. This check holds every such file to that: each top-level key
|
||||
// whose value holds `${secret:…}`, anywhere below it, is either listed in the file's `protected`, or left out
|
||||
// of the file's `takes-settings` (novox/hq ADR 0326), so that no layer reaches it. A file whose text is a template
|
||||
// in files/ is judged by the template's text.
|
||||
//
|
||||
// A secret nested below a key the file takes for its other values (`server.secret_key` below `server`) cannot
|
||||
// be protected alone today: the controller protects top-level keys. Such a file is named in the file `known`
|
||||
// beside this check, one line per key, until the controller refuses a nested key. The list only shrinks: a
|
||||
// line there that no longer names a finding is refused, and so is a line the trunk's copy (the merge base's,
|
||||
// given with -base) does not hold. While the trunk holds no copy, the lines in seed are the trunk's.
|
||||
//
|
||||
// go run . [-base <the trunk's known>] <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Finding is one key of one mergeable file a layer could fill in place of the vault.
|
||||
type Finding struct {
|
||||
Module, ID, Key string
|
||||
}
|
||||
|
||||
func (f Finding) String() string { return f.Module + " " + f.ID + " " + f.Key }
|
||||
|
||||
// Check reads every manifest under root/modules and answers each secret-bearing key a layer could replace.
|
||||
func Check(root string) ([]Finding, error) {
|
||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(manifests)
|
||||
var out []Finding
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if how, _ := r["merge"].(string); how == "" {
|
||||
continue
|
||||
}
|
||||
id, _ := r["id"].(string)
|
||||
content, _ := r["content"].(string)
|
||||
// A file whose text is a template in its module's files/ (novox/hq ADR 0349 §2) is judged by that text,
|
||||
// exactly as it would be inline.
|
||||
if name, _ := r["template"].(string); name != "" {
|
||||
text, err := os.ReadFile(filepath.Join(filepath.Dir(path), filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %s names the template %s: %w", m.Module, id, name, err)
|
||||
}
|
||||
content = string(text)
|
||||
}
|
||||
var doc map[string]any
|
||||
if strings.TrimSpace(content) == "" {
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal([]byte(content), &doc); err != nil {
|
||||
return nil, fmt.Errorf("%s: %s merges as JSON and its content is not JSON: %w", m.Module, id, err)
|
||||
}
|
||||
protected := set(r["protected"])
|
||||
takes, names := r["takes-settings"]
|
||||
taken := set(takes)
|
||||
for _, key := range sortedKeys(doc) {
|
||||
for _, at := range secretsBelow(key, doc[key]) {
|
||||
switch {
|
||||
case protected[key]:
|
||||
case names && !taken[key]:
|
||||
default:
|
||||
out = append(out, Finding{m.Module, id, at})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// secretsBelow answers the dotted path of every string at or below value that names a vault secret.
|
||||
func secretsBelow(at string, value any) []string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
if strings.Contains(v, "${secret:") {
|
||||
return []string{at}
|
||||
}
|
||||
case map[string]any:
|
||||
var out []string
|
||||
for _, k := range sortedKeys(v) {
|
||||
out = append(out, secretsBelow(at+"."+k, v[k])...)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
var out []string
|
||||
for i, e := range v {
|
||||
out = append(out, secretsBelow(fmt.Sprintf("%s[%d]", at, i), e)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func set(v any) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
list, _ := v.([]any)
|
||||
for _, e := range list {
|
||||
if s, ok := e.(string); ok {
|
||||
out[s] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]any) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// Known reads the lines of the known list: "<module> <resource> <key>", # comments and blank lines skipped.
|
||||
func Known(path string) (map[string]bool, error) {
|
||||
f, err := os.Open(path)
|
||||
if os.IsNotExist(err) {
|
||||
return map[string]bool{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
out := map[string]bool{}
|
||||
s := bufio.NewScanner(f)
|
||||
for s.Scan() {
|
||||
line := strings.TrimSpace(s.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
out[strings.Join(strings.Fields(line), " ")] = true
|
||||
}
|
||||
return out, s.Err()
|
||||
}
|
||||
|
||||
// seed is the known list as it was first written, standing for the trunk's copy until the trunk holds one.
|
||||
var seed = map[string]bool{
|
||||
"searxng settings server.secret_key": true,
|
||||
}
|
||||
|
||||
// Added answers the lines of known that base does not hold: a line may leave the list, never join it.
|
||||
func Added(known, base map[string]bool) []string {
|
||||
var out []string
|
||||
for line := range known {
|
||||
if !base[line] {
|
||||
out = append(out, line)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Judge splits the findings into those refused and the known lines that no longer name one.
|
||||
func Judge(found []Finding, known map[string]bool) (refused []Finding, stale []string) {
|
||||
seen := map[string]bool{}
|
||||
for _, f := range found {
|
||||
seen[f.String()] = true
|
||||
if !known[f.String()] {
|
||||
refused = append(refused, f)
|
||||
}
|
||||
}
|
||||
for line := range known {
|
||||
if !seen[line] {
|
||||
stale = append(stale, line)
|
||||
}
|
||||
}
|
||||
sort.Strings(stale)
|
||||
return refused, stale
|
||||
}
|
||||
|
||||
// remedy says how a finding is put right.
|
||||
func remedy(key string) string {
|
||||
top, _, nested := strings.Cut(key, ".")
|
||||
if !nested {
|
||||
return fmt.Sprintf("list %q in the file's \"protected\", so the controller refuses a setting of it", top)
|
||||
}
|
||||
return fmt.Sprintf("the controller protects a top-level key only; list %q in the file's \"protected\" if "+
|
||||
"nothing else below it is the operator's, or leave it out of the file's \"takes-settings\"", top)
|
||||
}
|
||||
|
||||
func main() {
|
||||
basePath := flag.String("base", "", "the trunk's copy of known (the merge base's); seed when absent")
|
||||
flag.Parse()
|
||||
root := "."
|
||||
if flag.NArg() > 0 {
|
||||
root = flag.Arg(0)
|
||||
}
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
known, err := Known("known")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
base := seed
|
||||
if *basePath != "" {
|
||||
if base, err = Known(*basePath); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
added := Added(known, base)
|
||||
for _, line := range added {
|
||||
fmt.Printf("checks/secrets/known gains %q, which the trunk's copy does not hold: the list only shrinks — "+
|
||||
"protect the key instead (novox/hq issue 342)\n", line)
|
||||
}
|
||||
refused, stale := Judge(found, known)
|
||||
for _, f := range refused {
|
||||
fmt.Printf("%s: %s fills %s with a vault secret, and a setting could replace it (novox/hq issue 342): %s\n",
|
||||
f.Module, f.ID, f.Key, remedy(f.Key))
|
||||
}
|
||||
for _, line := range stale {
|
||||
fmt.Printf("checks/secrets/known names %q, which no longer holds a secret a setting could replace: "+
|
||||
"remove the line\n", line)
|
||||
}
|
||||
if len(refused) > 0 || len(stale) > 0 || len(added) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("secrets: no setting can replace a vault secret in a mergeable file (%d known, nested)\n", len(known))
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func manifest(t *testing.T, root, module, resources string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, "modules", module)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := `{"module":"` + module + `","resources":[` + resources + `]}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASecretASettingCouldReplaceIsRefused(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// Refused: the key holds the vault's secret and nothing keeps a layer off it.
|
||||
manifest(t, root, "a", `{"id":"cfg","type":"file","merge":"json","content":"{\"password\": \"${secret:admin}\"}"}`)
|
||||
// Refused: nested below a key the file takes.
|
||||
manifest(t, root, "b", `{"id":"cfg","type":"file","merge":"json","takes-settings":["server"],`+
|
||||
`"content":"{\"server\": {\"secret_key\": \"${secret:s}\", \"port\": 1}}"}`)
|
||||
// Kept: protected.
|
||||
manifest(t, root, "c", `{"id":"cfg","type":"file","merge":"json","protected":["password"],`+
|
||||
`"content":"{\"password\": \"${secret:admin}\", \"user\": \"admin\"}"}`)
|
||||
// Kept: the file names its keys, and not this one.
|
||||
manifest(t, root, "d", `{"id":"cfg","type":"file","merge":"json","takes-settings":["ui"],`+
|
||||
`"content":"{\"token\": \"${secret:t}\", \"ui\": {}}"}`)
|
||||
// Kept: a file that does not merge takes no setting.
|
||||
manifest(t, root, "e", `{"id":"cfg","type":"file","content":"{\"token\": \"${secret:t}\"}"}`)
|
||||
// Refused: protecting another key does not protect this one; a list element counts too.
|
||||
manifest(t, root, "f", `{"id":"cfg","type":"file","merge":"json","protected":["user"],`+
|
||||
`"content":"{\"users\": [{\"password\": \"${secret:p}\"}]}"}`)
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"a cfg password", "b cfg server.secret_key", "f cfg users[0].password"}
|
||||
if len(found) != len(want) {
|
||||
t.Fatalf("found %v, want %v", found, want)
|
||||
}
|
||||
for i, f := range found {
|
||||
if f.String() != want[i] {
|
||||
t.Fatalf("found %v, want %v", found, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A mergeable file whose text is a template in files/ (novox/hq ADR 0349 §2) is judged by the template's text,
|
||||
// exactly as the same text inline; a template the module's directory lacks is an error, never a silent pass.
|
||||
func TestAMergeableFilesTemplateIsJudgedAsItsText(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "a", `{"id":"cfg","type":"file","merge":"json","template":"files/cfg.json"}`)
|
||||
manifest(t, root, "c", `{"id":"cfg","type":"file","merge":"json","protected":["password"],"template":"files/cfg.json"}`)
|
||||
for _, m := range []string{"a", "c"} {
|
||||
dir := filepath.Join(root, "modules", m, "files")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "cfg.json"), []byte(`{"password": "${secret:admin}"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 1 || found[0].String() != "a cfg password" {
|
||||
t.Fatalf("found %v, want [a cfg password]", found)
|
||||
}
|
||||
|
||||
manifest(t, root, "g", `{"id":"cfg","type":"file","merge":"json","template":"files/gone.json"}`)
|
||||
if _, err := Check(root); err == nil {
|
||||
t.Fatal("a template the module's directory lacks passed the check")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheKnownListOnlyShrinks(t *testing.T) {
|
||||
found := []Finding{{"b", "cfg", "server.secret_key"}, {"a", "cfg", "password"}}
|
||||
refused, stale := Judge(found, map[string]bool{"b cfg server.secret_key": true, "gone cfg token": true})
|
||||
if len(refused) != 1 || refused[0].String() != "a cfg password" {
|
||||
t.Fatalf("refused %v", refused)
|
||||
}
|
||||
if len(stale) != 1 || stale[0] != "gone cfg token" {
|
||||
t.Fatalf("stale %v", stale)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCatalogueHoldsNoSecretASettingCouldReplace(t *testing.T) {
|
||||
found, err := Check("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
known, err := Known("known")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refused, stale := Judge(found, known)
|
||||
if len(refused) > 0 || len(stale) > 0 {
|
||||
t.Fatalf("refused %v, stale %v", refused, stale)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALineTheTrunkDoesNotHoldIsRefused(t *testing.T) {
|
||||
trunk := map[string]bool{"b cfg server.secret_key": true}
|
||||
// A key unprotected on the branch, and its line added to the list: refused, though it matches a finding.
|
||||
branch := map[string]bool{"b cfg server.secret_key": true, "a cfg password": true}
|
||||
if added := Added(branch, trunk); len(added) != 1 || added[0] != "a cfg password" {
|
||||
t.Fatalf("added %v", added)
|
||||
}
|
||||
// A line removed is no addition.
|
||||
if added := Added(map[string]bool{}, trunk); len(added) != 0 {
|
||||
t.Fatalf("added %v", added)
|
||||
}
|
||||
// Until the trunk holds a copy, the seed stands for it, and the list as committed is within it.
|
||||
known, err := Known("known")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if added := Added(known, seed); len(added) != 0 {
|
||||
t.Fatalf("known holds lines the seed does not: %v", added)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCheckRefusesALineAddedAgainstTheTrunk(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "a", `{"id":"cfg","type":"file","merge":"json","content":"{\"password\": \"${secret:admin}\"}"}`)
|
||||
dir := t.TempDir()
|
||||
base := filepath.Join(dir, "base")
|
||||
if err := os.WriteFile(base, []byte("# the trunk's\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
branch := map[string]bool{"a cfg password": true}
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
trunk, err := Known(base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refused, stale := Judge(found, branch)
|
||||
if len(refused) != 0 || len(stale) != 0 {
|
||||
t.Fatalf("the line hides the finding from Judge alone: %v %v", refused, stale)
|
||||
}
|
||||
if added := Added(branch, trunk); len(added) != 1 {
|
||||
t.Fatalf("an added line passed: %v", added)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/sshdir
|
||||
|
||||
go 1.22
|
||||
@@ -1,161 +0,0 @@
|
||||
// Command sshdir refuses any manifest but modules/ssh-client naming a path under ~/.ssh (novox/hq ADR 0415 §6,
|
||||
// issue 1020): ssh-client owns ~/.ssh and sweeps what it does not know, so a path another module placed there
|
||||
// would be moved out at the next sweep. A module that needs something there provides it to ssh-client instead.
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// Every string of a manifest is read, wherever it stands, except prose and a file's text (Prose): a path, a
|
||||
// place, a volume; a path inside a directory of the module's own (`${dir:<id>}`) is not ~/.ssh. Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SSHModule is the one module allowed to name a path under ~/.ssh.
|
||||
const SSHModule = "ssh-client"
|
||||
|
||||
// underSSH is a path under ~/.ssh, or ~/.ssh itself, however the home is spelled: ${machine:account-home}/.ssh,
|
||||
// ~/.ssh, .ssh/config, or a volume's host side (…/.ssh:/root/.ssh).
|
||||
var underSSH = regexp.MustCompile(`(^|[/~])\.ssh($|[/:])`)
|
||||
|
||||
// Prose are the keys whose strings are words or a file's text, not a path: a template may say ~/.ssh in a comment.
|
||||
var Prose = map[string]bool{"description": true, "why": true, "does": true, "template": true, "content": true, "title": true}
|
||||
|
||||
// Finding is one path under ~/.ssh in a manifest other than ssh-client's.
|
||||
type Finding struct {
|
||||
Module string
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
// Check reads every manifest under root/modules and answers which paths it refuses.
|
||||
func Check(root string) ([]Finding, error) {
|
||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(manifests)
|
||||
var out []Finding
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
module := filepath.Base(filepath.Dir(path))
|
||||
if obj, ok := m.(map[string]any); ok {
|
||||
if name, ok := obj["module"].(string); ok && name != "" {
|
||||
module = name
|
||||
}
|
||||
}
|
||||
if module == SSHModule {
|
||||
continue
|
||||
}
|
||||
walk(m, "", dirsOf(m), func(key, value string) {
|
||||
out = append(out, Finding{Module: module, Key: key, Value: value})
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// walk calls found for every string under v, outside Prose, that names a path under ~/.ssh.
|
||||
func walk(v any, key string, dirs map[string]string, found func(key, value string)) {
|
||||
switch x := v.(type) {
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(x))
|
||||
for k := range x {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
if !Prose[k] {
|
||||
walk(x[k], k, dirs, found)
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, e := range x {
|
||||
walk(e, key, dirs, found)
|
||||
}
|
||||
case string:
|
||||
// A path under a directory the mesh places for the module (${dir:<id>} of a directory with a place and no path,
|
||||
// as a runs-as module's home) is that account's own .ssh, not the operator's ~/.ssh that ssh-client sweeps
|
||||
// (issue 1484, ADR 0466 §4). Any other ${dir:<id>} is resolved to the path its resource declares and judged.
|
||||
if resolved, placed := resolveDir(x, dirs); !placed && underSSH.MatchString(resolved) {
|
||||
found(key, x)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var ofDir = regexp.MustCompile(`^\$\{dir:([a-z0-9][a-z0-9_-]*)\}`)
|
||||
|
||||
// placedDir marks a directory the mesh places for the module: it has no path of its own.
|
||||
const placedDir = "\x00placed"
|
||||
|
||||
// dirsOf are the module's directories by id: the path each declares, or placedDir for one with a place and no path.
|
||||
func dirsOf(m any) map[string]string {
|
||||
dirs := map[string]string{}
|
||||
obj, _ := m.(map[string]any)
|
||||
list, _ := obj["resources"].([]any)
|
||||
for _, r := range list {
|
||||
res, _ := r.(map[string]any)
|
||||
id, _ := res["id"].(string)
|
||||
path, _ := res["path"].(string)
|
||||
switch {
|
||||
case id == "":
|
||||
case path != "":
|
||||
dirs[id] = path
|
||||
case res["type"] == "directory":
|
||||
dirs[id] = placedDir
|
||||
}
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
// resolveDir follows a leading ${dir:<id>} through the module's directories. It answers the path with the references
|
||||
// resolved, and whether it ends under a placed directory.
|
||||
func resolveDir(x string, dirs map[string]string) (string, bool) {
|
||||
for i := 0; i < 8; i++ {
|
||||
m := ofDir.FindStringSubmatch(x)
|
||||
if m == nil {
|
||||
return x, false
|
||||
}
|
||||
to, ok := dirs[m[1]]
|
||||
if !ok {
|
||||
return x, false
|
||||
}
|
||||
if to == placedDir {
|
||||
return x, true
|
||||
}
|
||||
x = to + strings.TrimPrefix(x, m[0])
|
||||
}
|
||||
return x, false
|
||||
}
|
||||
|
||||
func main() {
|
||||
root := "."
|
||||
if len(os.Args) > 1 {
|
||||
root = os.Args[1]
|
||||
}
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, f := range found {
|
||||
fmt.Printf("%s names %q (%s) under ~/.ssh: only %s may, and the sweep moves anything else out (novox/hq ADR 0415 §6)\n", f.Module, f.Value, f.Key, SSHModule)
|
||||
}
|
||||
if len(found) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("~/.ssh: named only by ssh-client")
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func manifest(t *testing.T, root, module, body string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, "modules", module)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnotherModuleNamingSSHIsRefused(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "ssh-client", `{"module":"ssh-client","resources":[{"path":"${machine:account-home}/.ssh/config"}]}`)
|
||||
manifest(t, root, "forge-account", `{"module":"forge-account","resources":[{"path":"${machine:account-home}/.ssh/config.d/20-forge"}]}`)
|
||||
manifest(t, root, "roster", `{"module":"roster","facts":{"hosts":{"path":".ssh/known_hosts"}}}`)
|
||||
manifest(t, root, "box", `{"module":"box","containers":[{"volumes":["~/.ssh:/root/.ssh:ro"]}]}`)
|
||||
manifest(t, root, "quiet", `{"module":"quiet","description":"reads ~/.ssh/config","resources":[{"path":"${machine:account-home}/.sshrc-not","template":"# see ~/.ssh/config"},{"path":"/etc/ssh/sshd_config"}]}`)
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, f := range found {
|
||||
got[f.Module] = true
|
||||
}
|
||||
if len(found) != 3 || !got["forge-account"] || !got["roster"] || !got["box"] {
|
||||
t.Fatalf("refused %+v; want forge-account, roster and box", found)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1484: a runs-as module's own account has a .ssh of its own, inside its home (a directory of the module's);
|
||||
// that is not the operator's ~/.ssh, which only ssh-client names. A path outside any ${dir:} is still refused.
|
||||
func TestAModulesOwnDirectoryMayHoldADotSSH(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "builder", `{"module":"builder","resources":[{"id":"state","type":"directory","place":"."},{"id":"ssh-dir","type":"directory","path":"${dir:state}/.ssh"},{"path":"${dir:ssh-dir}/config"}]}`)
|
||||
manifest(t, root, "other", `{"module":"other","resources":[{"path":"${machine:account-home}/.ssh/config"}]}`)
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 1 || found[0].Module != "other" {
|
||||
t.Fatalf("refused %+v; want only other", found)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1484 review: only a directory the mesh places is the module's own home. A ${dir:} that resolves to the
|
||||
// operator's home is judged by the path it resolves to.
|
||||
func TestADirResolvingToTheOperatorsHomeIsStillRefused(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manifest(t, root, "escape", `{"module":"escape","resources":[{"id":"h","type":"directory","path":"${machine:account-home}","unknown":"module"},{"path":"${dir:h}/.ssh/config"}]}`)
|
||||
manifest(t, root, "placed", `{"module":"placed","resources":[{"id":"state","type":"directory","place":".","unknown":"program"},{"id":"s","type":"directory","path":"${dir:state}/.ssh"},{"path":"${dir:s}/config"}]}`)
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 1 || found[0].Module != "escape" {
|
||||
t.Fatalf("refused %+v; want only escape", found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCatalogueHasNone(t *testing.T) {
|
||||
found, err := Check("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 0 {
|
||||
t.Fatalf("the catalogue names paths under ~/.ssh outside ssh-client: %+v", found)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/templates
|
||||
|
||||
go 1.22
|
||||
@@ -1,9 +0,0 @@
|
||||
# The modules that still keep a file's text inline in module.json (`content`), one per line: the catalogue's list of
|
||||
# modules whose file text has not yet moved to a template in files/ (novox/hq ADR 0349 §2, design 57), or in the
|
||||
# templates directory its manifest names (`templates`, issue 731). Every other module's file resources name their
|
||||
# template. This list only shrinks: a module that keeps no file text inline is taken off, and a line the trunk's copy
|
||||
# does not hold is refused. When it is empty, no manifest in the catalogue carries file text (design 57 package T5,
|
||||
# issue 658).
|
||||
#
|
||||
# A window-manager addition (`contributions`, such as files/i3/*.conf) is not a file resource and takes no template;
|
||||
# it moves with the collections (design 57 package L2, issue 681) and is not judged here.
|
||||
@@ -1,178 +0,0 @@
|
||||
// Command templates refuses a file's text kept inline in a manifest, outside the modules still to move (novox/hq
|
||||
// ADR 0349 §2, design 57 package T5, issue 658).
|
||||
//
|
||||
// A file resource names its text as a template in its module's templates directory (`"template": "<dir>/<name>"`;
|
||||
// the directory is the manifest's `"templates"`, files/ when it says none), which the module's build packs into an
|
||||
// archive; the controller reads each template back into the file's content, so the
|
||||
// mesh holds and composes what it would for the same text inline. A module whose file text has not yet moved is
|
||||
// listed in the file `inline` beside this check, one per line. Every other module's file resources say no
|
||||
// `content`; a module on the list that keeps none inline is taken off it; and a line the trunk's copy (the merge
|
||||
// base's, given with -base) does not hold is refused, so the list only shrinks; while the trunk holds no copy, the
|
||||
// modules in seed are the trunk's. When it is empty, no manifest in the catalogue carries file text.
|
||||
//
|
||||
// Only file resources are judged: a window-manager addition (`contributions`) takes no template and moves with the
|
||||
// collections (design 57 package L2, issue 681).
|
||||
//
|
||||
// go run . [-base <the trunk's inline>] <catalogue root>
|
||||
//
|
||||
// Every refusal names the refused module's own templates directory. Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
listFile = "inline"
|
||||
listPathInCatalog = "checks/templates/" + listFile
|
||||
)
|
||||
|
||||
// Check answers, per module that keeps any, the ids of its file resources that say their text inline.
|
||||
func Check(root string) (map[string][]string, error) {
|
||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(manifests)
|
||||
out := map[string][]string{}
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if _, inline := r["content"]; inline && r["type"] == "file" {
|
||||
out[m.Module] = append(out[m.Module], fmt.Sprint(r["id"]))
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// TemplatesDir answers the directory a module keeps its templates in, with a trailing slash: its manifest's
|
||||
// `templates`, files/ when the manifest says none or cannot be read here.
|
||||
func TemplatesDir(root, module string) string {
|
||||
dir := "files/"
|
||||
raw, err := os.ReadFile(filepath.Join(root, "modules", module, "module.json"))
|
||||
if err != nil {
|
||||
return dir
|
||||
}
|
||||
var m struct {
|
||||
Templates string `json:"templates"`
|
||||
}
|
||||
if json.Unmarshal(raw, &m) == nil && m.Templates != "" {
|
||||
dir = strings.TrimSuffix(m.Templates, "/") + "/"
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// Refusal is the finding for a module that keeps file text inline and is not on the list.
|
||||
func Refusal(module string, ids []string, dir string) string {
|
||||
return fmt.Sprintf("%s keeps the text of %s inline and is not on %s: keep each file's text in %s and name it as "+
|
||||
"the file's template, packed by the build's archive from %s (novox/hq ADR 0349 §2)",
|
||||
module, strings.Join(ids, ", "), listPathInCatalog, dir, dir)
|
||||
}
|
||||
|
||||
// Gained is the finding for a module the list gains against the trunk's copy.
|
||||
func Gained(module, dir string) string {
|
||||
return fmt.Sprintf("%s gains %s, which the trunk's copy does not hold: the list only shrinks — move its file text "+
|
||||
"to %s instead (novox/hq ADR 0349 §2)", listPathInCatalog, module, dir)
|
||||
}
|
||||
|
||||
// seed is the list as it was first written, standing for the trunk's copy until the trunk holds one.
|
||||
var seed = map[string]bool{"asus-zephyrus-g14": true, "power": true, "showcase": true}
|
||||
|
||||
// Listed is the modules a list names, one per line; a line starting with # and a blank line are not modules.
|
||||
func Listed(path string) (map[string]bool, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
out := map[string]bool{}
|
||||
s := bufio.NewScanner(f)
|
||||
for s.Scan() {
|
||||
line := strings.TrimSpace(s.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
out[line] = true
|
||||
}
|
||||
return out, s.Err()
|
||||
}
|
||||
|
||||
// Judge answers the modules that keep file text inline and are not on the list (refused), the modules on the list
|
||||
// that keep none (stale), and the modules on the list that the trunk's list does not hold (added).
|
||||
func Judge(found map[string][]string, listed, base map[string]bool) (refused, stale, added []string) {
|
||||
for m := range found {
|
||||
if !listed[m] {
|
||||
refused = append(refused, m)
|
||||
}
|
||||
}
|
||||
for m := range listed {
|
||||
if _, keeps := found[m]; !keeps {
|
||||
stale = append(stale, m)
|
||||
}
|
||||
if !base[m] {
|
||||
added = append(added, m)
|
||||
}
|
||||
}
|
||||
sort.Strings(refused)
|
||||
sort.Strings(stale)
|
||||
sort.Strings(added)
|
||||
return refused, stale, added
|
||||
}
|
||||
|
||||
func main() {
|
||||
basePath := flag.String("base", "", "the trunk's copy of inline (the merge base's); seed when absent")
|
||||
flag.Parse()
|
||||
root := "."
|
||||
if flag.NArg() > 0 {
|
||||
root = flag.Arg(0)
|
||||
}
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
listed, err := Listed(listFile)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
base := seed
|
||||
if *basePath != "" {
|
||||
if base, err = Listed(*basePath); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
refused, stale, added := Judge(found, listed, base)
|
||||
for _, m := range refused {
|
||||
fmt.Println(Refusal(m, found[m], TemplatesDir(root, m)))
|
||||
}
|
||||
for _, m := range stale {
|
||||
fmt.Printf("%s names %s, which keeps no file text inline: take it off the list\n", listPathInCatalog, m)
|
||||
}
|
||||
for _, m := range added {
|
||||
fmt.Println(Gained(m, TemplatesDir(root, m)))
|
||||
}
|
||||
if len(refused) > 0 || len(stale) > 0 || len(added) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("templates: no module keeps a file's text inline but the %d listed\n", len(listed))
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func module(t *testing.T, root, name, body string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, "modules", name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// A file resource that says its text inline is found, by module and resource; one naming its template, a
|
||||
// resource that is not a file and a window-manager addition are not (novox/hq ADR 0349 §2).
|
||||
func TestEveryInlineFileTextIsFound(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
module(t, root, "inline", `{"module":"inline","resources":[{"id":"a","type":"file","content":"x"},`+
|
||||
`{"id":"b","type":"file","template":"files/b"},{"id":"c","type":"file","content":""}]}`)
|
||||
module(t, root, "templated", `{"module":"templated","resources":[{"id":"a","type":"file","template":"files/a"},`+
|
||||
`{"id":"d","type":"directory","path":"/x"}],"contributions":{"i3":[{"content":"bindsym x"}]}}`)
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string][]string{"inline": {"a", "c"}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("found %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A module not on the list keeps no file text inline; one on the list that keeps none is taken off it; a line the
|
||||
// trunk's list does not hold is refused, so the list only shrinks until it is empty.
|
||||
func TestTheListOfModulesKeepingTextInlineOnlyShrinks(t *testing.T) {
|
||||
found := map[string][]string{"old": {"a"}, "forgotten": {"b"}}
|
||||
inline := map[string]bool{"old": true, "done": true, "new": true}
|
||||
base := map[string]bool{"old": true, "done": true, "forgotten": true}
|
||||
refused, stale, added := Judge(found, inline, base)
|
||||
if !reflect.DeepEqual(refused, []string{"forgotten"}) || !reflect.DeepEqual(stale, []string{"done", "new"}) ||
|
||||
!reflect.DeepEqual(added, []string{"new"}) {
|
||||
t.Fatalf("refused %v, stale %v, added %v", refused, stale, added)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheListIsReadWithoutItsComments(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "inline")
|
||||
if err := os.WriteFile(path, []byte("# why\n\npower\n showcase \n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Listed(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, map[string]bool{"power": true, "showcase": true}) {
|
||||
t.Fatalf("read %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue itself: no module keeps a file's text inline but those the list names, and the list names none
|
||||
// that keeps it no longer (design 57 package T5, issue 658: the ten modules without a build moved).
|
||||
func TestTheCatalogueKeepsFileTextInlineOnlyWhereListed(t *testing.T) {
|
||||
found, err := Check("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inline, err := Listed(listFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refused, stale, _ := Judge(found, inline, inline)
|
||||
if len(refused) > 0 || len(stale) > 0 {
|
||||
t.Fatalf("keeping file text inline and not listed: %v; listed and keeping none: %v", refused, stale)
|
||||
}
|
||||
}
|
||||
|
||||
// A refusal names the refused module's own templates directory, not files/ when the module names another (issue 809).
|
||||
func TestARefusalNamesTheModulesOwnTemplatesDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
module(t, root, "own", `{"module":"own","templates":"t/","resources":[{"id":"a","type":"file","content":"x"}]}`)
|
||||
module(t, root, "plain", `{"module":"plain","resources":[{"id":"a","type":"file","content":"x"}]}`)
|
||||
module(t, root, "bare", `{"module":"bare","templates":"templates","resources":[]}`)
|
||||
for module, dir := range map[string]string{"own": "t/", "plain": "files/", "bare": "templates/"} {
|
||||
if got := TemplatesDir(root, module); got != dir {
|
||||
t.Fatalf("%s: directory %q, want %q", module, got, dir)
|
||||
}
|
||||
}
|
||||
msg := Refusal("own", []string{"a"}, TemplatesDir(root, "own"))
|
||||
if !strings.Contains(msg, "t/") || strings.Contains(msg, "files/") {
|
||||
t.Fatalf("refusal %q", msg)
|
||||
}
|
||||
if msg := Gained("own", TemplatesDir(root, "own")); !strings.Contains(msg, "t/") || strings.Contains(msg, "files/") {
|
||||
t.Fatalf("gained %q", msg)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/toolchains
|
||||
|
||||
go 1.22
|
||||
@@ -1,105 +0,0 @@
|
||||
// Command toolchains refuses any manifest but the owner's declaring a language's toolchain package (novox/hq
|
||||
// ADR 0477 clause 4, issue 1573): the package go only in modules/go, nodejs and npm only in modules/nodejs, and buildah (issue 1702) only in modules/buildah.
|
||||
// Everything that compiles or runs that language requires the owner's provision instead (go-toolchain,
|
||||
// node-toolchain), so the package is declared once, and a node without the owner refuses the module that
|
||||
// needs it at resolution rather than failing at its first build. The owners must declare their packages.
|
||||
//
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Owners names, for each toolchain package, the one module that declares it.
|
||||
var Owners = map[string]string{
|
||||
"buildah": "buildah",
|
||||
"go": "go",
|
||||
"nodejs": "nodejs",
|
||||
"npm": "nodejs",
|
||||
}
|
||||
|
||||
// Finding is one refusal: Module declares Package, which Owner alone may, or (Missing) Owner does not declare
|
||||
// the Package it owns.
|
||||
type Finding struct {
|
||||
Module, Package, Owner string
|
||||
Missing bool
|
||||
}
|
||||
|
||||
// Check reads every manifest under root/modules and answers what it refuses.
|
||||
func Check(root string) ([]Finding, error) {
|
||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(manifests)
|
||||
var out []Finding
|
||||
declared := map[string]map[string]bool{} // module -> packages
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Resources []struct {
|
||||
Type string `json:"type"`
|
||||
Package string `json:"package"`
|
||||
} `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
declared[m.Module] = map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
if r.Type != "package" {
|
||||
continue
|
||||
}
|
||||
declared[m.Module][r.Package] = true
|
||||
if owner, ok := Owners[r.Package]; ok && owner != m.Module {
|
||||
out = append(out, Finding{Module: m.Module, Package: r.Package, Owner: owner})
|
||||
}
|
||||
}
|
||||
}
|
||||
var pkgs []string
|
||||
for p := range Owners {
|
||||
pkgs = append(pkgs, p)
|
||||
}
|
||||
sort.Strings(pkgs)
|
||||
for _, p := range pkgs {
|
||||
if !declared[Owners[p]][p] {
|
||||
out = append(out, Finding{Module: Owners[p], Package: p, Owner: Owners[p], Missing: true})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
root := "."
|
||||
if len(os.Args) > 1 {
|
||||
root = os.Args[1]
|
||||
}
|
||||
found, err := Check(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, f := range found {
|
||||
if f.Missing {
|
||||
fmt.Printf("%s does not declare the package %s it owns (novox/hq ADR 0477 clause 4)\n", f.Owner, f.Package)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%s declares the package %s: only %s may; require its provision instead (novox/hq ADR 0477 clause 4)\n",
|
||||
f.Module, f.Package, f.Owner)
|
||||
}
|
||||
if len(found) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("toolchains: go, nodejs, npm and buildah are declared only by their owners")
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func catalogue(t *testing.T, manifests map[string]string) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for name, body := range manifests {
|
||||
dir := filepath.Join(root, "modules", name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestOnlyTheOwnerDeclaresAToolchainPackage(t *testing.T) {
|
||||
root := catalogue(t, map[string]string{
|
||||
"go": `{"module":"go","resources":[{"id":"go","type":"package","package":"go"}]}`,
|
||||
"nodejs": `{"module":"nodejs","resources":[{"id":"nodejs","type":"package","package":"nodejs"},{"id":"npm","type":"package","package":"npm"}]}`,
|
||||
"buildah": `{"module":"buildah","resources":[{"id":"b","type":"package","package":"buildah"}]}`,
|
||||
"lab": `{"module":"lab","resources":[{"id":"git","type":"package","package":"git"},{"id":"go","type":"package","package":"go"}]}`,
|
||||
"builder": `{"module":"builder","resources":[{"id":"n","type":"package","package":"nodejs"},{"id":"dir","type":"directory","path":"/go"}]}`,
|
||||
})
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []Finding{{Module: "builder", Package: "nodejs", Owner: "nodejs"}, {Module: "lab", Package: "go", Owner: "go"}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("found %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASecondDeclarerOfBuildahIsRefused(t *testing.T) {
|
||||
root := catalogue(t, map[string]string{
|
||||
"go": `{"module":"go","resources":[{"id":"go","type":"package","package":"go"}]}`,
|
||||
"nodejs": `{"module":"nodejs","resources":[{"id":"nodejs","type":"package","package":"nodejs"},{"id":"npm","type":"package","package":"npm"}]}`,
|
||||
"buildah": `{"module":"buildah","resources":[{"id":"b","type":"package","package":"buildah"}]}`,
|
||||
"imagery": `{"module":"imagery","resources":[{"id":"b","type":"package","package":"buildah"}]}`,
|
||||
})
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []Finding{{Module: "imagery", Package: "buildah", Owner: "buildah"}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("found %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheOwnersMustExistAndDeclareTheirPackages(t *testing.T) {
|
||||
root := catalogue(t, map[string]string{
|
||||
"go": `{"module":"go","resources":[]}`,
|
||||
})
|
||||
got, err := Check(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 4 {
|
||||
t.Errorf("an owner missing its packages gave %v, want four findings (buildah, go, nodejs, npm)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue as it stands: this is the check itself, run here too.
|
||||
func TestTheCatalogueGivesEachToolchainPackageToItsOwnerAlone(t *testing.T) {
|
||||
got, err := Check("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("the catalogue declares a toolchain package outside its owner: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The daemons' lines (novox/hq ADR 0272, its insight of 2026-10-09): the operator types one command, `nox`
|
||||
// (`mesh-cli`). The node-engine (`nox-mesh-host`) and the controller (`mesh-controller`) are daemons, and a
|
||||
// person never types their names — so no text a module can show names a line of theirs. The list is the
|
||||
// copy in retired-lines at the catalogue's root, which novox/hq's words.py prints (`--list lines`) and
|
||||
// compares; one line each, `<daemon> <command>`, and a trailing ` …` for a command that is a line only with
|
||||
// an argument after it (`mesh-controller module check` is a line; "the mesh-controller module" is prose).
|
||||
|
||||
// retiredLine is one daemon's line: the daemon, its command, and whether an argument must follow.
|
||||
type retiredLine struct {
|
||||
daemon, command string
|
||||
withArgument bool
|
||||
}
|
||||
|
||||
// readLines reads retired-lines: one line a line; blank lines and lines starting with # are not lines.
|
||||
func readLines(path string) ([]retiredLine, error) {
|
||||
entries, err := readList(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []retiredLine
|
||||
for _, e := range entries {
|
||||
withArgument := strings.HasSuffix(e, " …")
|
||||
e = strings.TrimSuffix(e, " …")
|
||||
parts := strings.Fields(e)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("retired-lines: %q is not `<daemon> <command>`", e)
|
||||
}
|
||||
out = append(out, retiredLine{daemon: parts[0], command: parts[1], withArgument: withArgument})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// linePattern matches the line whole: the daemon, white space, the command. A command that is a line only
|
||||
// with an argument is matched as a line is written — at the start of a line or a code span, a prompt allowed,
|
||||
// with an argument after it — so prose that names the daemon beside the word ("the mesh-controller module is
|
||||
// assigned") is not one.
|
||||
func linePattern(l retiredLine) *regexp.Regexp {
|
||||
daemon, command := regexp.QuoteMeta(l.daemon), regexp.QuoteMeta(l.command)
|
||||
if l.withArgument {
|
||||
tick := "`"
|
||||
return regexp.MustCompile(`(?m)(?:^|` + tick + `)[ \t]*(?:\$[ \t]*)?(` + daemon + `\s+` + command + `\s+[^\s` + tick + `])`)
|
||||
}
|
||||
return regexp.MustCompile(`(?:^|[^\w-])(` + daemon + `\s+` + command + `)(?:$|[^\w-])`)
|
||||
}
|
||||
|
||||
// checkLines finds every daemon's line in the texts.
|
||||
func checkLines(lines []retiredLine, texts []text) []string {
|
||||
var out []string
|
||||
for _, l := range lines {
|
||||
rx := linePattern(l)
|
||||
for _, t := range texts {
|
||||
if m := rx.FindStringSubmatchIndex(t.s); m != nil {
|
||||
line := t.line
|
||||
if line > 0 {
|
||||
line += strings.Count(t.s[:m[2]], "\n")
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%s:%d: `%s %s` is a daemon's line — the operator types `nox …` (novox/hq "+
|
||||
"ADR 0272) — in %q", t.file, line, l.daemon, l.command, excerpt(t.s, m[2], m[3])))
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
+2
-23
@@ -2,9 +2,7 @@
|
||||
// 0244): no word the glossary retired for the tools' descriptions — the copy in retired-words at the
|
||||
// catalogue's root — in any text a module's tools can show. That text is every string literal in a module's
|
||||
// own code that is not a test (a tool's description, the notes and errors it answers with) and every
|
||||
// description in its manifest. Comments are not read: an agent never sees them. And no daemon's line
|
||||
// (novox/hq ADR 0272, lines.go): the copy in retired-lines, the lines a person would otherwise be told to
|
||||
// type of the node-engine's or the controller's binary, where the operator's command is `nox`.
|
||||
// description in its manifest. Comments are not read: an agent never sees them.
|
||||
//
|
||||
// A word is matched whole and in any case, a space in it matching any run of white space, and its plural
|
||||
// (`s` or `es` on its last part) is matched as the word. A vendor word is
|
||||
@@ -99,26 +97,7 @@ func main() {
|
||||
fmt.Printf("words: %d use(s) of a word the glossary retired (novox/hq ADR 0244) — say it in the glossary's word\n", len(findings))
|
||||
os.Exit(1)
|
||||
}
|
||||
// And no daemon's line (novox/hq ADR 0272): what a module tells the operator to type is a `nox` line.
|
||||
lines, err := readLines(filepath.Join(root, "retired-lines"))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "words:", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "words: retired-lines lists no line — the check would pass on anything")
|
||||
os.Exit(2)
|
||||
}
|
||||
if findings := checkLines(lines, texts); len(findings) > 0 {
|
||||
for _, f := range findings {
|
||||
fmt.Println(f)
|
||||
}
|
||||
fmt.Printf("words: %d daemon's line(s) in what a module shows — the operator types one command, nox (novox/hq ADR 0272)\n",
|
||||
len(findings))
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("words: %d retired words and %d daemons' lines, none in the %d strings of %d files\n", len(words), len(lines),
|
||||
len(texts), files)
|
||||
fmt.Printf("words: %d retired words, none in the %d strings of %d files\n", len(words), len(texts), files)
|
||||
}
|
||||
|
||||
// readList reads retired-words: one word or phrase a line; blank lines and lines starting with # are not words.
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
// A retired word's plural is the word; anything else joined on is another word.
|
||||
func TestPatternMatchesTheWordAndItsPlural(t *testing.T) {
|
||||
@@ -27,63 +24,3 @@ func TestPatternMatchesTheWordAndItsPlural(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A daemon's line is found whole — the daemon, its command, and an argument where the command is a line only with
|
||||
// one — and prose that merely names the daemon beside a word is not one (novox/hq ADR 0272).
|
||||
func TestADaemonsLineIsFoundWhole(t *testing.T) {
|
||||
push := retiredLine{daemon: "mesh-controller", command: "push"}
|
||||
module := retiredLine{daemon: "mesh-controller", command: "module", withArgument: true}
|
||||
handOver := retiredLine{daemon: "nox-mesh-host", command: "hand-over"}
|
||||
cases := []struct {
|
||||
line retiredLine
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{push, "run `mesh-controller push laptop` after", true},
|
||||
{push, "mesh-controller push", true},
|
||||
{push, "mesh-controller\n push", true},
|
||||
{push, "mesh-controller pushed it", false},
|
||||
{push, "the mesh-controller pushes", false},
|
||||
{push, "mesh-controller-push", false},
|
||||
{module, "`mesh-controller module check modules/*/module.json`", true},
|
||||
{module, "run\n $ mesh-controller module check x", true},
|
||||
{module, "the mesh-controller module is assigned", false},
|
||||
{module, "mesh-controller module", false},
|
||||
{module, "`mesh-controller module`", false},
|
||||
{handOver, "`nox-mesh-host hand-over /srv/x` at the machine", true},
|
||||
{handOver, "`nox node hand-over laptop /srv/x`", false},
|
||||
{retiredLine{daemon: "mesh-host", command: "hand-over"}, "nox-mesh-host hand-over", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := linePattern(c.line).MatchString(c.text); got != c.want {
|
||||
t.Errorf("%s %s on %q = %v, want %v", c.line.daemon, c.line.command, c.text, got, c.want)
|
||||
}
|
||||
}
|
||||
found := checkLines([]retiredLine{push, module}, []text{{file: "m/a.go", line: 3, s: "say\n`mesh-controller push`"},
|
||||
{file: "m/b.go", line: 1, s: "the mesh-controller module"}})
|
||||
if len(found) != 1 || found[0] != "m/a.go:4: `mesh-controller push` is a daemon's line — the operator types `nox …` (novox/hq ADR 0272) — in \"say `mesh-controller push`\"" {
|
||||
t.Fatalf("found %q", found)
|
||||
}
|
||||
}
|
||||
|
||||
// The list's lines read as `<daemon> <command>`, a trailing ` …` meaning an argument must follow; anything else is
|
||||
// refused rather than read as some line.
|
||||
func TestRetiredLinesAreReadAsDaemonAndCommand(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := dir + "/retired-lines"
|
||||
if err := os.WriteFile(path, []byte("# the list\nmesh-controller push\nmesh-controller module …\n\nnox-mesh-host hand-over\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines, err := readLines(path)
|
||||
if err != nil || len(lines) != 3 || lines[0] != (retiredLine{daemon: "mesh-controller", command: "push"}) ||
|
||||
lines[1] != (retiredLine{daemon: "mesh-controller", command: "module", withArgument: true}) ||
|
||||
lines[2] != (retiredLine{daemon: "nox-mesh-host", command: "hand-over"}) {
|
||||
t.Fatalf("read %+v, %v", lines, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("mesh-controller\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readLines(path); err == nil {
|
||||
t.Fatal("a line with no command was read")
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
58
|
||||
70
|
||||
The long-running resources of this catalogue that do not say how they are ready (`health`, novox/hq ADR 0240
|
||||
rule 8), as `module check` counts them. It may only go down: merge-check.sh fails a change that raises it, and
|
||||
one that lowers it writes the new number on the first line. From 2026-11-18, or once it is 0, a long-running
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
#!/bin/sh
|
||||
# The catalogue's TypeScript part of its own check (novox/hq issue 302's `mesh-check-also`), declared in
|
||||
# merge-check.sh's header and run by the build seat in the TypeScript toolchain, after the Go part. The media
|
||||
# catalogue's twin is issue 311. Before this, a TypeScript module's change ran nothing of its own before it
|
||||
# merged: the Go part said "NOT TESTED HERE", and a type error in the forge module would first have shown in
|
||||
# its walk.
|
||||
#
|
||||
# For every touched module that has a package.json, in a copy of it outside the clone:
|
||||
#
|
||||
# 1. its dependencies are the toolchain's own (/app/node_modules: the mesh's SDK, Node's types and what the
|
||||
# runtime carries), copied beside it, never fetched: the check holds no credential for the package
|
||||
# registry. A module that depends on a package the toolchain does not hold **fails the check**, by name:
|
||||
# it could not be type-checked or tested, and what could not run is a failure, never a note (novox/hq
|
||||
# issue 385);
|
||||
# 2. every source that is not a test is type-checked with the toolchain's compiler and the flags a build
|
||||
# compiles with — the same check the build makes, before the merge rather than in the walk;
|
||||
# 3. its tests (test/*.test.ts) run under Node's own runner with types stripped, as its own `npm test` does
|
||||
# after its build: the sources are compiled to dist/ and beside themselves first, so a test that imports
|
||||
# the compiled module, or a source that imports its sibling by its .js name, finds it.
|
||||
#
|
||||
# Said, never passed silently: a module with no tests is said NOT TESTED on every run that touches it —
|
||||
# nothing of it could fail. A module whose tests exist and could not run fails (novox/hq issue 385).
|
||||
#
|
||||
# By hand: MESH_CHECK_MODULES=gitea sh merge-check-typescript.sh, in the toolchain image (it reads
|
||||
# /app/node_modules), or with TOOLCHAIN_MODULES pointing at a node_modules that holds the same.
|
||||
#
|
||||
# **In an agent's session, outside the toolchain image** (novox/hq issue 742), the same node_modules is
|
||||
# assembled first, in a throwaway directory under the check's own workspace, the way the image's `deps` stage
|
||||
# assembles it: the toolchain's own package.json and lock (mesh-tools' node-tools/, read from the forge this
|
||||
# clone came from, so the list is never copied here) installed with `npm ci`, then the SDK the mesh last
|
||||
# published (`@novox/mesh-sdk@latest`), the @novox scope from the forge's package registry, read without a
|
||||
# credential. Node and npm are the dev-tools module's; nothing is installed on the machine, no npm
|
||||
# configuration or cache of the account's is read or written. Only when neither TOOLCHAIN_MODULES nor
|
||||
# /app/node_modules is there: the forge's check, in the image, fetches nothing, as before. The forge is
|
||||
# MESH_FORGE (default: the origin remote's), the owner MESH_FORGE_OWNER (default: the origin's owner).
|
||||
set -eu
|
||||
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
deps="${TOOLCHAIN_MODULES:-/app/node_modules}"
|
||||
if [ -z "${TOOLCHAIN_MODULES:-}" ] && [ ! -f "$deps/typescript/bin/tsc" ]; then
|
||||
origin=$(git remote get-url origin 2>/dev/null || true)
|
||||
forge="${MESH_FORGE:-$(printf '%s\n' "$origin" | sed -n 's#^\(https\{0,1\}://[^/]*\)/.*#\1#p')}"
|
||||
owner="${MESH_FORGE_OWNER:-$(printf '%s\n' "$origin" | sed -n 's#^https\{0,1\}://[^/]*/\([^/]*\)/.*#\1#p')}"
|
||||
if [ -z "$forge" ] || [ -z "$owner" ]; then
|
||||
echo "NOT CHECKED: no TypeScript toolchain at $deps, and no forge to assemble one from (origin is '$origin'; set MESH_FORGE and MESH_FORGE_OWNER, or TOOLCHAIN_MODULES)"
|
||||
exit 1
|
||||
fi
|
||||
echo "no TypeScript toolchain at $deps: assembling one from $forge/$owner/mesh-tools node-tools/ and $owner's package registry (novox/hq issue 742)"
|
||||
tc="$work/toolchain"
|
||||
mkdir -p "$tc"
|
||||
printf '@%s:registry=%s/api/packages/%s/npm/\n' "$owner" "$forge" "$owner" >"$work/npmrc"
|
||||
# Read with Node's own fetch (dev-tools declares Node, not curl); a status that is not 2xx fails.
|
||||
if ! { node -e '
|
||||
const [base, dir] = process.argv.slice(1);
|
||||
Promise.all(["package.json", "package-lock.json"].map(async (f) => {
|
||||
const r = await fetch(base + f);
|
||||
if (!r.ok) throw new Error(base + f + ": " + r.status);
|
||||
require("fs").writeFileSync(dir + "/" + f, await r.text());
|
||||
})).catch((e) => { console.error(e.message); process.exit(1); });' \
|
||||
"$forge/$owner/mesh-tools/raw/branch/main/node-tools/" "$tc" &&
|
||||
(cd "$tc" && export npm_config_userconfig="$work/npmrc" npm_config_cache="$work/npm-cache" &&
|
||||
npm ci --ignore-scripts --no-audit --no-fund --loglevel=error &&
|
||||
npm install --ignore-scripts --no-audit --no-fund --no-save --loglevel=error "@$owner/mesh-sdk@latest"); }; then
|
||||
echo "NOT CHECKED: the TypeScript toolchain could not be assembled from $forge (novox/hq issue 742)"
|
||||
exit 1
|
||||
fi
|
||||
deps="$tc/node_modules"
|
||||
fi
|
||||
tsc="$deps/typescript/bin/tsc"
|
||||
if [ ! -f "$tsc" ]; then
|
||||
echo "NOT CHECKED: no TypeScript compiler at $tsc — this part runs in the TypeScript toolchain"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The modules the change reaches: the planner's answer (hq ADR 0238), and every module a changed file is in —
|
||||
# a change to a module's tests alone moves no build (hq ADR 0267), and its tests must still run.
|
||||
touched=$({
|
||||
printf '%s\n' "${MESH_CHECK_MODULES:-}" | tr ',' '\n' | sed -e 's#^modules/##' -e '/\//d'
|
||||
printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p'
|
||||
} | sed '/^$/d' | sort -u)
|
||||
|
||||
failed=""
|
||||
for m in $touched; do
|
||||
d="modules/$m"
|
||||
[ -f "$d/package.json" ] || continue
|
||||
copy="$work/$m"
|
||||
mkdir -p "$copy"
|
||||
(cd "$d" && tar --exclude=./node_modules --exclude=./dist -cf - .) | (cd "$copy" && tar -xf -)
|
||||
# What it depends on, against what the toolchain holds.
|
||||
missing=$(node -e '
|
||||
const p = require(process.argv[1]); const have = require("fs").readdirSync(process.argv[2]);
|
||||
const scoped = (n) => n.startsWith("@") ? n.split("/")[0] : n;
|
||||
const names = Object.keys({ ...(p.dependencies || {}), ...(p.devDependencies || {}) });
|
||||
const held = (n) => have.includes(n) || (n.startsWith("@") && have.includes(scoped(n)) &&
|
||||
require("fs").existsSync(process.argv[2] + "/" + n));
|
||||
console.log(names.filter((n) => n !== "typescript" && !held(n)).join(" "));' "$copy/package.json" "$deps")
|
||||
if [ -n "$missing" ]; then
|
||||
echo "FAILED: $d could not be checked — it depends on $missing, which the toolchain does not hold and the check cannot fetch (novox/hq issue 385)"
|
||||
failed="$failed $m"
|
||||
continue
|
||||
fi
|
||||
mkdir -p "$copy/node_modules"
|
||||
for p in "$deps"/*; do
|
||||
case "$(basename "$p")" in typescript | esbuild | @esbuild) continue ;; esac
|
||||
cp -r "$p" "$copy/node_modules/"
|
||||
done
|
||||
sources=$(cd "$copy" && find . -name '*.ts' ! -name '*.d.ts' ! -path './test/*' ! -path './node_modules/*' ! -path './dist/*' | sort)
|
||||
if [ -n "$sources" ]; then
|
||||
echo "type-checking $d (TypeScript)"
|
||||
# shellcheck disable=SC2086 # the module's sources, none with a space in its name
|
||||
if ! (cd "$copy" && node "$tsc" --noEmit --module NodeNext --moduleResolution NodeNext --target ES2022 \
|
||||
--types node $sources); then
|
||||
failed="$failed $m"
|
||||
continue
|
||||
fi
|
||||
else
|
||||
echo "$d has no TypeScript sources of its own to type-check: its tests read its files"
|
||||
fi
|
||||
if ! ls "$copy"/test/*.test.ts >/dev/null 2>&1; then
|
||||
echo "NOT TESTED: $d is TypeScript and has no tests of its own"
|
||||
continue
|
||||
fi
|
||||
if [ -n "$sources" ]; then
|
||||
# shellcheck disable=SC2086
|
||||
(cd "$copy" && node "$tsc" --module NodeNext --moduleResolution NodeNext --target ES2022 --types node \
|
||||
--rootDir . --outDir dist $sources && node "$tsc" --module NodeNext --moduleResolution NodeNext \
|
||||
--target ES2022 --types node --rootDir . --outDir . $sources)
|
||||
fi
|
||||
echo "testing $d (TypeScript)"
|
||||
if ! (cd "$copy" && node --test --test-reporter=spec --experimental-strip-types \
|
||||
--no-warnings=ExperimentalWarning test/*.test.ts); then
|
||||
failed="$failed $m"
|
||||
fi
|
||||
done
|
||||
if [ -n "$failed" ]; then
|
||||
echo "TypeScript modules that failed:$failed"
|
||||
exit 1
|
||||
fi
|
||||
+7
-135
@@ -1,6 +1,5 @@
|
||||
#!/bin/sh
|
||||
# mesh-check-toolchain: go
|
||||
# mesh-check-also: typescript merge-check-typescript.sh
|
||||
#
|
||||
# The catalogue's own check (novox/hq ADR 0237 as amended): the second layer of a pull request's merge
|
||||
# check, `mesh/repo-check`, run by the build seat in the mesh's Go toolchain.
|
||||
@@ -14,13 +13,8 @@
|
||||
# it shares a rule with refused (MESH_GATE is the controller the mesh runs) — and the count of
|
||||
# long-running resources without `health` held to the number in health-undeclared, which only goes down;
|
||||
# 2. the Go tests of every module the change touches that has them, under the race detector when the
|
||||
# toolchain has a C compiler. A requirement of the mesh's own forge the module cache does not hold (a
|
||||
# pinned SDK commit) is taken from its repository's clone beside the check (checks/deps); a module whose
|
||||
# dependencies still cannot be had **fails the check**, by name and why — its tests could not run, and a
|
||||
# test that could not run is a failure, never a note (novox/hq issue 385). The integration tests (`//go:build
|
||||
# integration`) run here, with -tags integration, and only here. A module written in
|
||||
# TypeScript is type-checked and tested by
|
||||
# merge-check-typescript.sh, declared above, in the TypeScript toolchain (novox/hq issues 302, 311);
|
||||
# toolchain has a C compiler — and a module whose dependencies cannot be fetched here, or that is
|
||||
# written in TypeScript, is said as not tested, never passed silently;
|
||||
# 3. the words (novox/hq ADR 0244): no word the glossary retired for the tools, as copied in
|
||||
# retired-words, in any text a module's tools can show an agent — every string in its own code that is
|
||||
# not a test, and every description in its manifest. Run over every module, not only the touched ones,
|
||||
@@ -28,102 +22,9 @@
|
||||
# 4. the paths (novox/hq ADR 0260): no module names a path under /run, /var/lib, /usr/local/lib or /etc
|
||||
# that another module's manifest places — modules talk over the bus, not through each other's files.
|
||||
# Over every module; checks/paths/shared and checks/paths/known narrow it, and known only shrinks.
|
||||
# 5. the agent's home (novox/hq ADR 0266): no module unpacks an archive, or writes into a file, under
|
||||
# ${machine:agent-home} — the node-engine does both after checking the path, and the agent account owns
|
||||
# that home and could swap a link in between.
|
||||
# 6. the secrets (novox/hq issue 342): no mergeable file fills a key with a vault secret that a setting could
|
||||
# replace — the key is `protected`, or left out of the file's `takes-settings`. checks/secrets/known names
|
||||
# the nested ones the controller cannot protect alone yet, and only shrinks.
|
||||
# 7. the list of predefined keys (novox/hq ADR 0348 clause 7): a change to predefined.json removes no key a
|
||||
# module still names, judged against the trunk's copy at the merge base. Whether a removed key still holds a
|
||||
# value at the mesh or a node is mesh-config's to refuse; this check reads no store. The list itself and
|
||||
# every manifest against it are judged by the controller's module check in step 1.
|
||||
# 8. the config form (novox/hq ADR 0348 clauses 1 and 11): no module says `settings`, `takes-settings` or
|
||||
# `${setting:<key>}`, in its manifest or a template it names, or has a mergeable file naming no keys (which
|
||||
# takes its whole layer), unless checks/config/unmoved lists it as not yet moved; that list only shrinks,
|
||||
# judged against the trunk's copy at the merge base.
|
||||
# 9. the file text (novox/hq ADR 0349 §2): no module keeps a file's text inline in its manifest (`content`)
|
||||
# unless checks/templates/inline lists it as not yet moved; each names its template in files/. That list
|
||||
# only shrinks, judged against the trunk's copy at the merge base.
|
||||
# 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.
|
||||
# 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.
|
||||
set -eu
|
||||
|
||||
(cd checks/words && go test -count=1 . && go run . ../..)
|
||||
# Node names (novox/hq ADR 0507 §9, issue 1838): no node name of this mesh in a covered module; the names are in
|
||||
# `particulars`, the covered modules in checks/particulars/covered.
|
||||
(cd checks/particulars && go test -count=1 . && go run . ../..)
|
||||
# The toolchains (novox/hq ADR 0477 clause 4): the package go only in modules/go, nodejs and npm only in modules/nodejs.
|
||||
(cd checks/toolchains && go test -count=1 . && go run . ../..)
|
||||
# The image builder (novox/hq ADR 0487 §3): only modules/buildah provides rootless-image-build, docker never.
|
||||
(cd checks/imagebuild && go test -count=1 . && go run . ../..)
|
||||
# The image's place (novox/hq ADR 0487 §1): every image builds from a Dockerfile in its own module's directory.
|
||||
(cd checks/imageplace && go test -count=1 . && go run . ../..)
|
||||
# The Node lock files (novox/hq ADR 0469 §5, issue 1868): a TypeScript module that names packages beyond the SDK commits its lock.
|
||||
(cd checks/locks && go test -count=1 . && go run . ../..)
|
||||
# The agent's home (novox/hq ADR 0266): no archive, and no file written into, under it.
|
||||
(cd checks/agenthome && go test -count=1 . && go run . ../..)
|
||||
# The mail-store seat (novox/hq ADR 0378 §3): only mesh-mail may claim it.
|
||||
(cd checks/mailstore && go test -count=1 . && go run . ../..)
|
||||
# ~/.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 . ../..)
|
||||
# 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=""
|
||||
secrets_ref="origin/${MESH_CHECK_BASE:-main}"
|
||||
if merge_base=$(git merge-base HEAD "$secrets_ref" 2>/dev/null); then
|
||||
if git cat-file -e "$merge_base:checks/secrets/known" 2>/dev/null; then
|
||||
secrets_base=$(mktemp)
|
||||
git show "$merge_base:checks/secrets/known" >"$secrets_base"
|
||||
fi
|
||||
else
|
||||
echo "NOT COMPARED WITH THE TRUNK: no merge base with $secrets_ref; checks/secrets/known is held to the list it was first written with"
|
||||
fi
|
||||
(cd checks/secrets && go test -count=1 . && if [ -n "$secrets_base" ]; then go run . -base "$secrets_base" ../..; else go run . ../..; fi)
|
||||
# The list of predefined keys (novox/hq ADR 0348 clause 7): no key removed that a module still names. The trunk's
|
||||
# list at the merge base is what it is judged against; a trunk without the file had none.
|
||||
predefined_base=""
|
||||
if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then
|
||||
predefined_base=$(mktemp)
|
||||
if ! git show "$merge_base:predefined.json" >"$predefined_base" 2>/dev/null; then
|
||||
echo '{}' >"$predefined_base"
|
||||
fi
|
||||
else
|
||||
echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; no removal from predefined.json can be seen"
|
||||
fi
|
||||
(cd checks/predefined && go test -count=1 . && if [ -n "$predefined_base" ]; then go run . -base "$predefined_base" ../..; else go run . ../..; fi)
|
||||
# The config form (novox/hq ADR 0348 §1, §11): a moved module says no old name, and the list of modules not yet moved
|
||||
# only shrinks, judged against the trunk's copy at the merge base; a trunk without one is the list's first copy.
|
||||
unmoved_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/config/unmoved" 2>/dev/null; then
|
||||
unmoved_base=$(mktemp)
|
||||
git show "$merge_base:checks/config/unmoved" >"$unmoved_base"
|
||||
fi
|
||||
else
|
||||
echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; checks/config/unmoved is held to the list it was first written with"
|
||||
fi
|
||||
(cd checks/config && go test -count=1 . && if [ -n "$unmoved_base" ]; then go run . -base "$unmoved_base" ../..; else go run . ../..; fi)
|
||||
# The file text (novox/hq ADR 0349 §2): a file's text is a template in files/, outside the modules not yet moved; the
|
||||
# list of those only shrinks, judged against the trunk's copy at the merge base, a trunk without one being its first.
|
||||
inline_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/templates/inline" 2>/dev/null; then
|
||||
inline_base=$(mktemp)
|
||||
git show "$merge_base:checks/templates/inline" >"$inline_base"
|
||||
fi
|
||||
else
|
||||
echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; checks/templates/inline is held to the list it was first written with"
|
||||
fi
|
||||
(cd checks/templates && go test -count=1 . && if [ -n "$inline_base" ]; then go run . -base "$inline_base" ../..; else go run . ../..; fi)
|
||||
# The lists that narrow the paths check may not grow unseen: the base branch's copies are what they are
|
||||
# judged against (MESH_CHECK_BASE, main when unset), read from the clone the check runs in.
|
||||
base_lists=""
|
||||
@@ -169,26 +70,16 @@ if [ -n "${MESH_CHECK_MODULES:-}" ]; then
|
||||
else
|
||||
touched=$(printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p' | sort -u)
|
||||
fi
|
||||
# A module's dependencies: fetched, or a forge requirement taken from its clone beside the check
|
||||
# (MESH_CHECK_BESIDE); its tests run against them (checks/deps, novox/hq issue 385). The helper's own test
|
||||
# runs first.
|
||||
(cd checks/deps && go test -count=1 .)
|
||||
deps=$(mktemp -d)
|
||||
trap 'rm -rf "$deps"' EXIT
|
||||
(cd checks/deps && go build -o "$deps/deps" .)
|
||||
untestable=""
|
||||
for m in $touched; do
|
||||
[ -d "modules/$m" ] || continue
|
||||
if [ ! -f "modules/$m/go.mod" ]; then
|
||||
[ -f "modules/$m/package.json" ] && echo "modules/$m is TypeScript: merge-check-typescript.sh type-checks and tests it"
|
||||
[ -f "modules/$m/package.json" ] && echo "NOT TESTED HERE: modules/$m is TypeScript; the gate judges its manifest"
|
||||
continue
|
||||
fi
|
||||
if ! modfile=$(GOPRIVATE=git.novox.be "$deps/deps" -beside "${MESH_CHECK_BESIDE:-}" -work "$deps" "modules/$m"); then
|
||||
echo "FAILED: modules/$m could not be tested — $modfile"
|
||||
untestable="$untestable modules/$m"
|
||||
if ! (cd "modules/$m" && GOPRIVATE=git.novox.be go mod download >/dev/null 2>&1); then
|
||||
echo "NOT TESTED: modules/$m — its dependencies cannot be fetched by the build seat"
|
||||
continue
|
||||
fi
|
||||
[ -n "$modfile" ] && echo "modules/$m takes its forge dependencies from the clones beside the check"
|
||||
echo "testing modules/$m"
|
||||
unformatted=$(cd "modules/$m" && gofmt -l .)
|
||||
if [ -n "$unformatted" ]; then
|
||||
@@ -197,25 +88,6 @@ for m in $touched; do
|
||||
fi
|
||||
cgo=0
|
||||
[ -n "$race" ] && cgo=1
|
||||
# The integration tests (a file whose first line is `//go:build integration`: a test that starts a bus, a store or
|
||||
# another process) run here and nowhere else: the unit suite stays under a second (novox/hq playbook 07).
|
||||
(cd "modules/$m" && CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go vet -tags integration ${modfile:+"-modfile=$modfile"} ./... &&
|
||||
CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go test -tags integration ${modfile:+"-modfile=$modfile"} $race -count=1 ./...)
|
||||
# The docker tests of the bus's snapshot program and entrypoint (novox/hq ADR 0480 clause 6) start throwaway nats
|
||||
# containers, and the Go toolchain container holds no docker, so above they skip with a "NOT RUN" line. Where the
|
||||
# build seat says docker exists (MESH_CHECK_DOCKER=1) they run here by name, and a missing docker fails the check
|
||||
# (MESH_REQUIRE_DOCKER=1 makes the tests fail instead of skip). Without it the step says it did not run.
|
||||
if [ "$m" = nats ]; then
|
||||
if [ "${MESH_CHECK_DOCKER:-}" = 1 ]; then
|
||||
(cd modules/nats && MESH_REQUIRE_DOCKER=1 CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go test -tags integration ${modfile:+"-modfile=$modfile"} -count=1 \
|
||||
-run 'TestTheDeclaredDumpRunsInTheBussImage|TestTheEntrypointReloadsTheServerWithoutDroppingAConnection|TestTheStoreSurvivesTheNewDeclarationTheOldAndTheNewAgain|TestASnapshotOfALiveBusRestoresToTheSameContent|TestTheSnapshotUserCannotChangeTheBus' \
|
||||
./cmd/mesh-nats-snapshot) || { echo "the bus's docker tests failed"; exit 1; }
|
||||
else
|
||||
echo "NOT RUN: the bus's five docker tests (modules/nats/cmd/mesh-nats-snapshot) skipped: this check has no docker (MESH_CHECK_DOCKER is not 1)"
|
||||
fi
|
||||
fi
|
||||
(cd "modules/$m" && CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go vet ./... &&
|
||||
CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go test $race -count=1 ./...)
|
||||
done
|
||||
if [ -n "$untestable" ]; then
|
||||
echo "the check fails: the tests of$untestable could not run (novox/hq issue 385)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -22,11 +22,11 @@ for GTK, Qt, the portal and the cursor, dark by default. It claims no seat, beca
|
||||
|
||||
| path | class | from |
|
||||
|---|---|---|
|
||||
| `~/.config/gtk-3.0/settings.ini` | owned (the found file kept once) | [`files/gtk-settings.ini`](files/gtk-settings.ini) |
|
||||
| `~/.config/gtk-3.0/settings.ini` | owned (the found file kept once) | [`config/gtk-settings.ini`](config/gtk-settings.ini) |
|
||||
| `~/.config/gtk-4.0/settings.ini` | owned | the same file |
|
||||
| `~/.config/qt6ct/qt6ct.conf` | owned | [`files/qt6ct.conf`](files/qt6ct.conf) |
|
||||
| `~/.config/xdg-desktop-portal/portals.conf` | owned | [`files/portals.conf`](files/portals.conf) |
|
||||
| `~/.icons/default/index.theme` | owned | [`files/cursor-index.theme`](files/cursor-index.theme): the cursor for programs that read neither `XCURSOR_THEME` nor the resources |
|
||||
| `~/.config/qt6ct/qt6ct.conf` | owned | [`config/qt6ct.conf`](config/qt6ct.conf) |
|
||||
| `~/.config/xdg-desktop-portal/portals.conf` | owned | [`config/portals.conf`](config/portals.conf) |
|
||||
| `~/.icons/default/index.theme` | owned | [`config/cursor-index.theme`](config/cursor-index.theme): the cursor for programs that read neither `XCURSOR_THEME` nor the resources |
|
||||
|
||||
**`qt6ct.conf` is the mesh's now.** A change made in qt6ct's own window is replaced at the next push,
|
||||
and the window's saved geometry goes with it. The theme is this module's to say. Settings will make it
|
||||
|
||||
@@ -18,18 +18,10 @@ type manifest struct {
|
||||
Environment struct {
|
||||
Variables map[string]string `json:"variables"`
|
||||
} `json:"environment"`
|
||||
Items []struct {
|
||||
Collection string `json:"collection"`
|
||||
Attributes map[string]string `json:"attributes"`
|
||||
Template string `json:"template"`
|
||||
} `json:"items"`
|
||||
Shell []struct {
|
||||
For, Slot, Code string
|
||||
} `json:"shell"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
@@ -41,23 +33,6 @@ func readManifest(t *testing.T) manifest {
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The module's code and variables are the templates of its items (design 57 L3, issues 795 and 807).
|
||||
for _, it := range m.Items {
|
||||
text, err := os.ReadFile(filepath.Join("..", "..", it.Template))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts := strings.Split(it.Collection, ":")
|
||||
switch {
|
||||
case len(parts) == 3 && parts[0] == "shell":
|
||||
m.Shell = append(m.Shell, struct{ For, Slot, Code string }{parts[1], parts[2], string(text)})
|
||||
case it.Collection == "environment" && it.Attributes["kind"] == "variable":
|
||||
if m.Environment.Variables == nil {
|
||||
m.Environment.Variables = map[string]string{}
|
||||
}
|
||||
m.Environment.Variables[it.Attributes["name"]] = strings.TrimSuffix(string(text), "\n")
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -121,10 +96,9 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
|
||||
pkgs = append(pkgs, r["package"].(string))
|
||||
continue
|
||||
}
|
||||
_, inline := r["content"]
|
||||
if _, err := os.Stat(filepath.Join("..", "..", "files", sources[id])); err != nil || inline ||
|
||||
r["template"] != "files/"+sources[id] || r["into"] != nil || r["owner"] != "${machine:account}" {
|
||||
t.Errorf("%s is not files/%s, whole, as its template, and the account's", id, sources[id])
|
||||
raw, _ := os.ReadFile(filepath.Join("..", "..", "config", sources[id]))
|
||||
if r["content"] != string(raw) || r["into"] != nil || r["owner"] != "${machine:account}" {
|
||||
t.Errorf("%s is not config/%s, whole and the account's", id, sources[id])
|
||||
}
|
||||
if strings.Contains(r["path"].(string), "qt5ct") {
|
||||
t.Error("qt5ct: both workstations run Qt 6 programs through qt6ct; a second tool is a duplicate")
|
||||
@@ -133,7 +107,7 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
|
||||
if strings.Join(pkgs, ",") != "gnome-themes-extra,adwaita-icon-theme,adwaita-cursors,qt6ct,xdg-desktop-portal-gtk" {
|
||||
t.Fatalf("%v", pkgs)
|
||||
}
|
||||
qt := readINI(filepath.Join("..", "..", "files", "qt6ct.conf"))
|
||||
qt := readINI(filepath.Join("..", "..", "config", "qt6ct.conf"))
|
||||
if qt["Appearance"]["style"] != "Fusion" || qt["Appearance"]["custom_palette"] != "true" || !strings.Contains(qt["Fonts"]["general"], "Inter,11") {
|
||||
t.Fatalf("%v", qt)
|
||||
}
|
||||
@@ -142,7 +116,7 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
|
||||
t.Fatalf("qt6ct ships no %s", qt["Appearance"]["color_scheme_path"])
|
||||
}
|
||||
}
|
||||
gtk := readINI(filepath.Join("..", "..", "files", "gtk-settings.ini"))["Settings"]
|
||||
gtk := readINI(filepath.Join("..", "..", "config", "gtk-settings.ini"))["Settings"]
|
||||
if gtk["gtk-theme-name"] != "Adwaita" || gtk["gtk-application-prefer-dark-theme"] != "1" || gtk["gtk-font-name"] != "Inter 11" {
|
||||
t.Fatalf("%v", gtk)
|
||||
}
|
||||
@@ -270,31 +244,3 @@ func asJSON(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// No file resource carries its text inline: each names its template in files/, and the build packs files/
|
||||
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
|
||||
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
for _, r := range m.Resources {
|
||||
if _, inline := r["content"]; inline {
|
||||
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
|
||||
}
|
||||
if name, ok := r["template"].(string); ok {
|
||||
if _, err := os.Stat(filepath.Join("..", "..", name)); err != nil {
|
||||
t.Errorf("resource %v names %s: %v", r["id"], name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
packs := 0
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "archive" && a["from"] == "files" {
|
||||
packs++
|
||||
if a["name"] != "files" {
|
||||
t.Errorf("the templates archive is named %v, not files", a["name"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if packs != 1 {
|
||||
t.Errorf("the build packs files/ into %d archives, not one", packs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Adwaita:dark
|
||||
@@ -1 +0,0 @@
|
||||
/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc
|
||||
@@ -1 +0,0 @@
|
||||
qt6ct
|
||||
@@ -1 +0,0 @@
|
||||
6
|
||||
@@ -1 +0,0 @@
|
||||
Fusion
|
||||
@@ -1 +0,0 @@
|
||||
24
|
||||
@@ -1 +0,0 @@
|
||||
Adwaita
|
||||
@@ -1,10 +0,0 @@
|
||||
# The appearance (module adwaita): GSettings is where the portal reads dark or light, and the portal is
|
||||
# the only way it reaches Electron, Chromium, Firefox and flatpaks. Set at every session start to the
|
||||
# module's default; adwaita_appearance switches it for a session.
|
||||
gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' || true
|
||||
gsettings set org.gnome.desktop.interface gtk-theme 'Adwaita' || true
|
||||
gsettings set org.gnome.desktop.interface icon-theme 'Adwaita' || true
|
||||
gsettings set org.gnome.desktop.interface cursor-theme 'Adwaita' || true
|
||||
gsettings set org.gnome.desktop.interface cursor-size 24 || true
|
||||
gsettings set org.gnome.desktop.interface font-name 'Inter 11' || true
|
||||
gsettings set org.gnome.desktop.interface monospace-font-name 'JetBrainsMono Nerd Font 11' || true
|
||||
@@ -1,3 +0,0 @@
|
||||
! adwaita: the cursor, for X programs that take it from the resources.
|
||||
Xcursor.theme: Adwaita
|
||||
Xcursor.size: 24
|
||||
+23
-81
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"module": "adwaita",
|
||||
"version": "1",
|
||||
"moved": true,
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
@@ -11,79 +10,27 @@
|
||||
"adwaita_icons",
|
||||
"adwaita_portal_check"
|
||||
],
|
||||
"items": [
|
||||
"environment": {
|
||||
"variables": {
|
||||
"GTK_THEME": "Adwaita:dark",
|
||||
"GTK2_RC_FILES": "/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc",
|
||||
"QT_QPA_PLATFORMTHEME": "qt6ct",
|
||||
"QT_STYLE_OVERRIDE": "Fusion",
|
||||
"QT_SELECT": "6",
|
||||
"XCURSOR_THEME": "Adwaita",
|
||||
"XCURSOR_SIZE": "24"
|
||||
}
|
||||
},
|
||||
"shell": [
|
||||
{
|
||||
"collection": "environment",
|
||||
"name": "gtk-theme",
|
||||
"attributes": {
|
||||
"kind": "variable",
|
||||
"name": "GTK_THEME"
|
||||
},
|
||||
"template": "files/env-gtk-theme"
|
||||
"for": "xresources",
|
||||
"slot": "normal",
|
||||
"code": "! adwaita: the cursor, for X programs that take it from the resources.\nXcursor.theme: Adwaita\nXcursor.size: 24\n"
|
||||
},
|
||||
{
|
||||
"collection": "environment",
|
||||
"name": "gtk2-rc-files",
|
||||
"attributes": {
|
||||
"kind": "variable",
|
||||
"name": "GTK2_RC_FILES"
|
||||
},
|
||||
"template": "files/env-gtk2-rc-files"
|
||||
},
|
||||
{
|
||||
"collection": "environment",
|
||||
"name": "qt-qpa-platformtheme",
|
||||
"attributes": {
|
||||
"kind": "variable",
|
||||
"name": "QT_QPA_PLATFORMTHEME"
|
||||
},
|
||||
"template": "files/env-qt-qpa-platformtheme"
|
||||
},
|
||||
{
|
||||
"collection": "environment",
|
||||
"name": "qt-style-override",
|
||||
"attributes": {
|
||||
"kind": "variable",
|
||||
"name": "QT_STYLE_OVERRIDE"
|
||||
},
|
||||
"template": "files/env-qt-style-override"
|
||||
},
|
||||
{
|
||||
"collection": "environment",
|
||||
"name": "qt-select",
|
||||
"attributes": {
|
||||
"kind": "variable",
|
||||
"name": "QT_SELECT"
|
||||
},
|
||||
"template": "files/env-qt-select"
|
||||
},
|
||||
{
|
||||
"collection": "environment",
|
||||
"name": "xcursor-theme",
|
||||
"attributes": {
|
||||
"kind": "variable",
|
||||
"name": "XCURSOR_THEME"
|
||||
},
|
||||
"template": "files/env-xcursor-theme"
|
||||
},
|
||||
{
|
||||
"collection": "environment",
|
||||
"name": "xcursor-size",
|
||||
"attributes": {
|
||||
"kind": "variable",
|
||||
"name": "XCURSOR_SIZE"
|
||||
},
|
||||
"template": "files/env-xcursor-size"
|
||||
},
|
||||
{
|
||||
"collection": "shell:xresources:normal",
|
||||
"name": "xresources-normal",
|
||||
"template": "files/xresources-normal"
|
||||
},
|
||||
{
|
||||
"collection": "shell:xinitrc:normal",
|
||||
"name": "xinitrc-normal",
|
||||
"template": "files/xinitrc-normal"
|
||||
"for": "xinitrc",
|
||||
"slot": "normal",
|
||||
"code": "# The appearance (module adwaita): GSettings is where the portal reads dark or light, and the portal is\n# the only way it reaches Electron, Chromium, Firefox and flatpaks. Set at every session start to the\n# module's default; adwaita_appearance switches it for a session.\ngsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' || true\ngsettings set org.gnome.desktop.interface gtk-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface icon-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface cursor-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface cursor-size 24 || true\ngsettings set org.gnome.desktop.interface font-name 'Inter 11' || true\ngsettings set org.gnome.desktop.interface monospace-font-name 'JetBrainsMono Nerd Font 11' || true\n"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
@@ -118,7 +65,7 @@
|
||||
"path": "${machine:account-home}/.config/gtk-3.0/settings.ini",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0644",
|
||||
"template": "files/gtk-settings.ini"
|
||||
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at\n# every push; adwaita_appearance switches dark and light for the running session.\n[Settings]\ngtk-theme-name=Adwaita\ngtk-icon-theme-name=Adwaita\ngtk-cursor-theme-name=Adwaita\ngtk-cursor-theme-size=24\ngtk-font-name=Inter 11\ngtk-application-prefer-dark-theme=1\n"
|
||||
},
|
||||
{
|
||||
"id": "gtk4",
|
||||
@@ -126,7 +73,7 @@
|
||||
"path": "${machine:account-home}/.config/gtk-4.0/settings.ini",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0644",
|
||||
"template": "files/gtk-settings.ini"
|
||||
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at\n# every push; adwaita_appearance switches dark and light for the running session.\n[Settings]\ngtk-theme-name=Adwaita\ngtk-icon-theme-name=Adwaita\ngtk-cursor-theme-name=Adwaita\ngtk-cursor-theme-size=24\ngtk-font-name=Inter 11\ngtk-application-prefer-dark-theme=1\n"
|
||||
},
|
||||
{
|
||||
"id": "qt6ct",
|
||||
@@ -134,7 +81,7 @@
|
||||
"path": "${machine:account-home}/.config/qt6ct/qt6ct.conf",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0644",
|
||||
"template": "files/qt6ct.conf"
|
||||
"content": "[Appearance]\ncolor_scheme_path=/usr/share/qt6ct/colors/darker.conf\ncustom_palette=true\nicon_theme=Adwaita\nstandard_dialogs=default\nstyle=Fusion\n\n[Fonts]\nfixed=\"JetBrainsMono Nerd Font,11,-1,5,50,0,0,0,0,0\"\ngeneral=\"Inter,11,-1,5,50,0,0,0,0,0\"\n\n[Interface]\nactivate_item_on_single_click=1\nbuttonbox_layout=0\ncursor_flash_time=1000\ndialog_buttons_have_icons=1\ndouble_click_interval=400\ngui_effects=@Invalid()\nkeyboard_scheme=2\nmenus_have_icons=true\nshow_shortcuts_in_context_menus=true\nstylesheets=@Invalid()\ntoolbutton_style=4\nunderline_shortcut=1\nwheel_scroll_lines=3\n\n[Troubleshooting]\nforce_raster_widgets=1\nignored_applications=@Invalid()\n"
|
||||
},
|
||||
{
|
||||
"id": "portals",
|
||||
@@ -142,7 +89,7 @@
|
||||
"path": "${machine:account-home}/.config/xdg-desktop-portal/portals.conf",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0644",
|
||||
"template": "files/portals.conf"
|
||||
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208). Replaced at every push.\n#\n# Which portal backend answers each interface. i3 is not a desktop xdg-desktop-portal knows, so with\n# no preference it uses whichever backend happens to be installed: fine while gtk is the only one,\n# wrong the day another arrives as somebody else's dependency. Named instead. gtk also serves\n# org.freedesktop.appearance (dark or light) from GSettings, which the session's start sets.\n[preferred]\ndefault=gtk\n# Secrets for sandboxed programs come from the keyring's backend. Without this line no backend answers\n# the interface: gtk does not implement it, and gnome-keyring's names only GNOME as its desktop.\norg.freedesktop.impl.portal.Secret=gnome-keyring\n"
|
||||
},
|
||||
{
|
||||
"id": "cursor",
|
||||
@@ -150,7 +97,7 @@
|
||||
"path": "${machine:account-home}/.icons/default/index.theme",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0644",
|
||||
"template": "files/cursor-index.theme"
|
||||
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208): the default cursor theme, for programs that\n# read neither XCURSOR_THEME nor the X resources.\n[Icon Theme]\nName=Default\nInherits=Adwaita\n"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
@@ -165,11 +112,6 @@
|
||||
"loads": [
|
||||
"adwaita-tools"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# artifact-store-tools
|
||||
|
||||
Tools that say what the mesh's artifact store holds, and what the controller's records say of it
|
||||
(novox/hq ADR 0251 §1–3, to-be 51). It changes nothing on its machine; its only resources are its state
|
||||
directory and the file the mesh writes there from its setting `older-store-names`.
|
||||
(novox/hq ADR 0251 §1–3, to-be 51). It declares no resources and changes nothing on its machine.
|
||||
|
||||
## Why a module beside the store
|
||||
|
||||
@@ -19,7 +18,7 @@ on that machine.
|
||||
|---|---|---|
|
||||
| `artifact_store_repositories` | r | every repository: tags and what each names, how many manifests (tagged or not), size |
|
||||
| `artifact_store_usage` | r | the store's size, the largest repositories, bytes shared between repositories, bytes no manifest marks (what the nightly collector frees next) |
|
||||
| `artifact_store_references` | r | what the controller's records and the catalogue's definitions say of each manifest, counted and sized by state and repository; each manifest listed when one repository is asked; what the records keep that the store does not hold |
|
||||
| `artifact_store_references` | r | what the controller's records say of each manifest, counted and sized by state and repository; each manifest listed when one repository is asked; what the records keep that the store does not hold |
|
||||
| `artifact_store_collect` | a | a dry run unless `dry_run` is false: what the controller would let go of, and the bytes the nightly collector would free then and now. A real run needs `why` and asks the controller's `collect` |
|
||||
|
||||
### How the store is read
|
||||
@@ -44,7 +43,6 @@ it lists. The store's collector removes every blob no manifest marks, so these n
|
||||
| state | what | removable |
|
||||
|---|---|---|
|
||||
| `kept` | a definition names it, or one of the five most recent builds of a module the mesh holds; `why` says which | no |
|
||||
| `named-by-a-definition` | no record names it, and a definition in the catalogue pins it by digest, under the store's current name or an older one; `why` names the module (hq issue 323) | no |
|
||||
| `holder-of-kept-archive` | the manifest that keeps a kept archive's blob (hq issue 253) | no |
|
||||
| `eligible` | the mesh made it and keeps it for no reason | through the controller's `collect` |
|
||||
| `holder-of-eligible-archive` | the manifest that keeps an eligible archive's blob | with its archive |
|
||||
@@ -58,20 +56,6 @@ its own right; the controller lets an index go with the platform manifests only
|
||||
counted as the index's. Two indexes naming one platform give it the more keeping state. An index no
|
||||
record names leaves its platform manifests unrecorded with it.
|
||||
|
||||
**What the definitions pin** (hq issue 323). A definition may pin an image of the store the mesh did not
|
||||
make: pushed before the mesh built it, and pinned under the store's current name or a name it had before.
|
||||
No record of the controller names it, yet a machine runs it, so `artifact_store_references` reads every
|
||||
definition the catalogue holds (`mesh-catalog.catalog_modules`, then `catalog_module` for each, eight at
|
||||
a time and within twenty seconds) and finds every `<name>/<repository>@sha256:…` in any of its strings,
|
||||
under each of the store's names: `artifact-store://`, the store's address as the controller answers it,
|
||||
and the names it had before. Those are the mesh's setting `older-store-names` (space-separated, `none` by
|
||||
default, set with the controller's `settings`), which the mesh writes to the module's `store-names` file: a
|
||||
catalogue names no installation. When they cannot be read, the answer's first line says so. A manifest no record
|
||||
names that a definition pins is `named-by-a-definition`; one the records name keeps the records' state. A
|
||||
definition read at its commit is kept, so the next call asks only for the list. Definitions that could
|
||||
not be read are listed under `definitions.unread`, and the answer's first line says that what they pin
|
||||
reads as unrecorded until they are.
|
||||
|
||||
**Unrecorded copies of upstream images** are listed in `unrecorded_mirrors`: each manifest at the top of
|
||||
a repository only the build's mirror writes (`upstream/<host>/<path>`, or a module's `<module>/on-<argument>`
|
||||
from before hq ADR 0257) that no record names, with what it and its platforms mark alone. They are the
|
||||
@@ -104,14 +88,6 @@ with slashes, a cut listing refused), manifests read with one absent or unreadab
|
||||
digest never becoming a path outside the blobs, the mark (an index, a shared blob, a blob nothing marks),
|
||||
every state, what the records keep that the store lacks, bytes freed counting a blob shared with a kept
|
||||
manifest as kept, a dry run never confirming, a real run without `why` refused before anything is asked,
|
||||
the fall-back when the references let go of cannot be carried, a manifest a definition pins under the
|
||||
store's current or an older name, a definition not read said first, escalation through `sudo -n`, and that the
|
||||
tools served are exactly the manifest's `tools` and its `invokes` exactly the two controller verbs and the
|
||||
catalogue's two tools.
|
||||
the fall-back when the references let go of cannot be carried, escalation through `sudo -n`, and that the
|
||||
tools served are exactly the manifest's `tools` and its `invokes` exactly the two controller verbs.
|
||||
Any command other than the two read-only scripts fails the test.
|
||||
|
||||
## Files it does not know
|
||||
|
||||
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
|
||||
|
||||
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
|
||||
@@ -7,15 +7,12 @@ import (
|
||||
|
||||
// The states a manifest the store holds can be in, as the records see it (novox/hq to-be 51).
|
||||
const (
|
||||
StateKept = "kept"
|
||||
StateHolderKept = "holder-of-kept-archive"
|
||||
StateEligible = "eligible"
|
||||
StateHolderEligible = "holder-of-eligible-archive"
|
||||
StateCollectedPresent = "let-go-yet-present"
|
||||
StateNamedDocument = "named-document"
|
||||
// StateNamedByDefinition is a manifest no record names that a definition pins by digest, under the
|
||||
// store's current name or an older one (novox/hq issue 323): a machine may run it, so it is kept.
|
||||
StateNamedByDefinition = "named-by-a-definition"
|
||||
StateKept = "kept"
|
||||
StateHolderKept = "holder-of-kept-archive"
|
||||
StateEligible = "eligible"
|
||||
StateHolderEligible = "holder-of-eligible-archive"
|
||||
StateCollectedPresent = "let-go-yet-present"
|
||||
StateNamedDocument = "named-document"
|
||||
StateUnrecorded = "unrecorded"
|
||||
collectionRemovesThese = "eligible and holder-of-eligible-archive, through the controller's collect; nothing else"
|
||||
)
|
||||
@@ -48,8 +45,8 @@ var mirrorRepository = regexp.MustCompile(`^(upstream/[a-z0-9][a-z0-9._/-]*|[a-z
|
||||
// keeping orders the states a platform manifest may take from the indexes naming it: the most keeping
|
||||
// wins, so a platform two indexes name is as kept as the more kept of them.
|
||||
var keeping = map[string]int{
|
||||
StateKept: 0, StateNamedByDefinition: 1, StateNamedDocument: 2, StateHolderKept: 3, StateEligible: 4,
|
||||
StateHolderEligible: 5, StateCollectedPresent: 6,
|
||||
StateKept: 0, StateNamedDocument: 1, StateHolderKept: 2, StateEligible: 3, StateHolderEligible: 4,
|
||||
StateCollectedPresent: 5,
|
||||
}
|
||||
|
||||
// recordIndex finds a record by repository and digest.
|
||||
@@ -67,9 +64,8 @@ func indexRecords(r *Records) recordIndex {
|
||||
return idx
|
||||
}
|
||||
|
||||
// Classify gives every manifest the store holds one state. A manifest no record names that a definition
|
||||
// pins is named by a definition; defs may be nil, when the definitions were not read.
|
||||
func Classify(v *View, recs *Records, defs *Definitions) []Entry {
|
||||
// Classify gives every manifest the store holds one state.
|
||||
func Classify(v *View, recs *Records) []Entry {
|
||||
idx := indexRecords(recs)
|
||||
var out []Entry
|
||||
for _, name := range v.L.RepoNames() {
|
||||
@@ -103,12 +99,6 @@ func Classify(v *View, recs *Records, defs *Definitions) []Entry {
|
||||
e.State = StateNamedDocument
|
||||
}
|
||||
}
|
||||
if e.State == StateUnrecorded && defs != nil {
|
||||
for _, p := range defs.Pins[Key(name, d)] {
|
||||
e.State = StateNamedByDefinition
|
||||
e.Why = append(e.Why, p.why())
|
||||
}
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,343 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// What the definitions pin in the store (novox/hq issue 323). A definition may pin an image of the store by
|
||||
// digest that the mesh did not make: one pushed before the mesh built it, pinned under the store's current
|
||||
// name or under a name the store had before. No record of the controller names such a manifest, and yet a
|
||||
// machine runs it, so it is said to be named by a definition and never unrecorded.
|
||||
|
||||
// NamesFileEnv names the file the mesh writes from the setting older-store-names: a line `older=` with the
|
||||
// names the store had before, as a definition may still write them — host names, or host:port, separated by
|
||||
// spaces — or `none`. A catalogue names no installation, so the names are the mesh's setting, not the
|
||||
// manifest's.
|
||||
const NamesFileEnv = "MESH_ARTIFACT_STORE_NAMES_FILE"
|
||||
|
||||
// CatalogueModules and CatalogueModule are the catalogue's tools these definitions are read through.
|
||||
const (
|
||||
CatalogueModules = "mesh-catalog.catalog_modules"
|
||||
CatalogueModule = "mesh-catalog.catalog_module"
|
||||
)
|
||||
|
||||
// DefinitionsTimeout bounds reading every definition, below the runtime's thirty-second call limit; what
|
||||
// was read is kept, so a call that runs out reads only the rest the next time.
|
||||
const DefinitionsTimeout = 20 * time.Second
|
||||
|
||||
// definitionReaders is how many definitions are asked at once.
|
||||
const definitionReaders = 8
|
||||
|
||||
// StoreNames are the names a definition may pin an image of the store under: the scheme the mesh writes
|
||||
// (artifact-store://), the store's address now, and the names it had before.
|
||||
type StoreNames struct {
|
||||
Current []string
|
||||
Older []string
|
||||
}
|
||||
|
||||
// NamesOf is the store's names: its address as the controller answers it, and the older ones given.
|
||||
func NamesOf(address, older string) StoreNames {
|
||||
n := StoreNames{Current: []string{"artifact-store://"}}
|
||||
if a := strings.Trim(strings.TrimSpace(address), "/"); a != "" {
|
||||
n.Current = append(n.Current, a+"/")
|
||||
}
|
||||
for _, o := range strings.FieldsFunc(older, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' || r == '\t' }) {
|
||||
if o = strings.Trim(o, "/"); o != "" && o != "none" {
|
||||
n.Older = append(n.Older, o+"/")
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// OlderNames reads the older names from the file the mesh writes, and says why when it cannot.
|
||||
func OlderNames() (string, string) {
|
||||
path := os.Getenv(NamesFileEnv)
|
||||
if path == "" {
|
||||
return "", NamesFileEnv + " is not set, so no older name of the store was read"
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", "the store's older names could not be read: " + err.Error()
|
||||
}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if v, ok := strings.CutPrefix(strings.TrimSpace(line), "older="); ok {
|
||||
return v, ""
|
||||
}
|
||||
}
|
||||
return "", path + " holds no line older=, so no older name of the store was read"
|
||||
}
|
||||
|
||||
// Pin is one definition pinning a manifest of the store by digest.
|
||||
type Pin struct {
|
||||
Module string `json:"module"`
|
||||
// Name is the store's name it is pinned under, as written: "artifact-store://", an address, or an older name.
|
||||
Name string `json:"name"`
|
||||
Older bool `json:"older,omitempty"`
|
||||
}
|
||||
|
||||
// pinned is one pin found in a manifest: where in the store, and under which name.
|
||||
type pinned struct {
|
||||
Repository, Digest, Name string
|
||||
Older bool
|
||||
}
|
||||
|
||||
var pinShapes sync.Map // name -> *regexp.Regexp
|
||||
|
||||
// pinShape is `<name><repository>@sha256:<hex>`, the name standing on its own: not the tail of a longer host.
|
||||
func pinShape(name string) *regexp.Regexp {
|
||||
if re, ok := pinShapes.Load(name); ok {
|
||||
return re.(*regexp.Regexp)
|
||||
}
|
||||
re := regexp.MustCompile(`(?:^|[^A-Za-z0-9.-])` + regexp.QuoteMeta(name) +
|
||||
`([a-z0-9][a-z0-9._/-]*)@(sha256:[0-9a-f]{64})`)
|
||||
pinShapes.Store(name, re)
|
||||
return re
|
||||
}
|
||||
|
||||
// PinsIn finds every image of the store a manifest pins by digest, in any string it holds: a resource's
|
||||
// image, a build's base, a file's content.
|
||||
func PinsIn(manifest json.RawMessage, names StoreNames) []pinned {
|
||||
var doc any
|
||||
if json.Unmarshal(manifest, &doc) != nil {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []pinned
|
||||
var walk func(any)
|
||||
walk = func(x any) {
|
||||
switch v := x.(type) {
|
||||
case map[string]any:
|
||||
for _, e := range v {
|
||||
walk(e)
|
||||
}
|
||||
case []any:
|
||||
for _, e := range v {
|
||||
walk(e)
|
||||
}
|
||||
case string:
|
||||
for _, group := range []struct {
|
||||
names []string
|
||||
older bool
|
||||
}{{names.Current, false}, {names.Older, true}} {
|
||||
for _, name := range group.names {
|
||||
for _, m := range pinShape(name).FindAllStringSubmatch(v, -1) {
|
||||
p := pinned{Repository: strings.Trim(m[1], "/"), Digest: m[2], Name: name, Older: group.older}
|
||||
if k := p.Repository + "@" + p.Digest + " " + p.Name; !seen[k] {
|
||||
seen[k] = true
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
return out
|
||||
}
|
||||
|
||||
// Definitions is what the catalogue's definitions pin in the store, and what of them could not be read.
|
||||
type Definitions struct {
|
||||
// Pins are by Key(repository, digest).
|
||||
Pins map[string][]Pin
|
||||
// Read is how many definitions were read; Unread says, per module, why its definition was not.
|
||||
Read int
|
||||
Unread map[string]string
|
||||
// NotAsked says why the catalogue could not be asked at all, when it could not.
|
||||
NotAsked string
|
||||
// NamesNotRead says why the store's older names could not be read, when they could not.
|
||||
NamesNotRead string
|
||||
Names StoreNames
|
||||
}
|
||||
|
||||
// Catalogue reads the definitions through the catalogue's tools, and keeps each definition it read by its
|
||||
// module and commit, which never change together.
|
||||
type Catalogue struct {
|
||||
Ask Asking
|
||||
|
||||
mu sync.Mutex
|
||||
kept map[string][]pinned // "<module>@<commit>|<names>" -> its pins
|
||||
}
|
||||
|
||||
type catalogueEntry struct {
|
||||
Module string `json:"module"`
|
||||
Commit string `json:"commit"`
|
||||
}
|
||||
|
||||
// Read asks the catalogue for every module and reads each definition, bounded by DefinitionsTimeout.
|
||||
func (c *Catalogue) Read(ctx context.Context, names StoreNames) *Definitions {
|
||||
defs := &Definitions{Pins: map[string][]Pin{}, Unread: map[string]string{}, Names: names}
|
||||
if c == nil || c.Ask == nil {
|
||||
defs.NotAsked = "this bundle has no way to ask the catalogue"
|
||||
return defs
|
||||
}
|
||||
raw, err := c.Ask(CatalogueModules, map[string]any{})
|
||||
if err != nil {
|
||||
defs.NotAsked = "the catalogue's modules could not be asked: " + err.Error()
|
||||
return defs
|
||||
}
|
||||
answer, output, ok := answerOf(raw)
|
||||
if !ok {
|
||||
defs.NotAsked = "the catalogue refused its modules: " + lastLine(output)
|
||||
return defs
|
||||
}
|
||||
var list struct {
|
||||
Modules []catalogueEntry `json:"modules"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(answer, &list); err != nil || list.Error != "" || list.Modules == nil {
|
||||
defs.NotAsked = "the catalogue's modules answer is not readable: " + firstOf(list.Error, errText(err), lastLine(output))
|
||||
return defs
|
||||
}
|
||||
c.mu.Lock()
|
||||
if c.kept == nil {
|
||||
c.kept = map[string][]pinned{}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
nameKey := strings.Join(names.Current, ",") + ";" + strings.Join(names.Older, ",")
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, DefinitionsTimeout)
|
||||
defer cancel()
|
||||
type result struct {
|
||||
module string
|
||||
pins []pinned
|
||||
why string
|
||||
}
|
||||
results := make(chan result, len(list.Modules))
|
||||
work := make(chan catalogueEntry)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < definitionReaders; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for m := range work {
|
||||
pins, why := c.definition(m, names, nameKey)
|
||||
results <- result{m.Module, pins, why}
|
||||
}
|
||||
}()
|
||||
}
|
||||
go func() {
|
||||
defer close(work)
|
||||
for _, m := range list.Modules {
|
||||
select {
|
||||
case work <- m:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
go func() { wg.Wait(); close(results) }()
|
||||
|
||||
got := map[string]bool{}
|
||||
collect:
|
||||
for {
|
||||
select {
|
||||
case r, open := <-results:
|
||||
if !open {
|
||||
break collect
|
||||
}
|
||||
got[r.module] = true
|
||||
if r.why != "" {
|
||||
defs.Unread[r.module] = r.why
|
||||
continue
|
||||
}
|
||||
defs.Read++
|
||||
for _, p := range r.pins {
|
||||
k := Key(p.Repository, p.Digest)
|
||||
defs.Pins[k] = append(defs.Pins[k], Pin{Module: r.module, Name: p.Name, Older: p.Older})
|
||||
}
|
||||
case <-ctx.Done():
|
||||
break collect
|
||||
}
|
||||
}
|
||||
for _, m := range list.Modules {
|
||||
if !got[m.Module] {
|
||||
defs.Unread[m.Module] = fmt.Sprintf("not read within %d s", int(DefinitionsTimeout/time.Second))
|
||||
}
|
||||
}
|
||||
for k := range defs.Pins {
|
||||
sort.Slice(defs.Pins[k], func(i, j int) bool { return defs.Pins[k][i].Module < defs.Pins[k][j].Module })
|
||||
}
|
||||
return defs
|
||||
}
|
||||
|
||||
// definition reads one module's definition, or answers it from what was read before.
|
||||
func (c *Catalogue) definition(m catalogueEntry, names StoreNames, nameKey string) ([]pinned, string) {
|
||||
key := m.Module + "@" + m.Commit + "|" + nameKey
|
||||
if m.Commit != "" {
|
||||
c.mu.Lock()
|
||||
pins, had := c.kept[key]
|
||||
c.mu.Unlock()
|
||||
if had {
|
||||
return pins, ""
|
||||
}
|
||||
}
|
||||
args := map[string]any{"module": m.Module}
|
||||
if m.Commit != "" {
|
||||
args["commit"] = m.Commit
|
||||
}
|
||||
raw, err := c.Ask(CatalogueModule, args)
|
||||
if err != nil {
|
||||
return nil, "could not be asked: " + err.Error()
|
||||
}
|
||||
answer, output, ok := answerOf(raw)
|
||||
if !ok {
|
||||
return nil, "refused: " + lastLine(output)
|
||||
}
|
||||
var one struct {
|
||||
Module *struct {
|
||||
Manifest json.RawMessage `json:"manifest"`
|
||||
} `json:"module"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(answer, &one); err != nil || one.Error != "" || one.Module == nil || len(one.Module.Manifest) == 0 {
|
||||
return nil, "not readable: " + firstOf(one.Error, errText(err), "no manifest in the answer")
|
||||
}
|
||||
pins := PinsIn(one.Module.Manifest, names)
|
||||
if m.Commit != "" {
|
||||
c.mu.Lock()
|
||||
c.kept[key] = pins
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return pins, ""
|
||||
}
|
||||
|
||||
// why says which definitions name a manifest, and under which of the store's names.
|
||||
func (p Pin) why() string {
|
||||
if p.Older {
|
||||
return fmt.Sprintf("a definition names it: %s, under %s, an older name of the store", p.Module, shownName(p.Name))
|
||||
}
|
||||
return "a definition names it: " + p.Module
|
||||
}
|
||||
|
||||
func firstOf(s ...string) string {
|
||||
for _, x := range s {
|
||||
if strings.TrimSpace(x) != "" {
|
||||
return x
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func errText(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// shownName is a name of the store as a person reads it: a host without the slash a pin puts after it, the
|
||||
// scheme as written.
|
||||
func shownName(name string) string {
|
||||
if strings.HasSuffix(name, "://") {
|
||||
return name
|
||||
}
|
||||
return strings.TrimSuffix(name, "/")
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A definition may pin an image of the store the mesh did not make, under a name the store had before
|
||||
// (novox/hq issue 323): three apps the anchor runs are pinned so. No record names them; a definition does.
|
||||
|
||||
const oldName = "registry-api.example.org"
|
||||
|
||||
// container is a definition whose one container runs image.
|
||||
func container(image string) string {
|
||||
return fmt.Sprintf(`{"module":"x","resources":[{"id":"server","type":"container","image":%q}]}`, image)
|
||||
}
|
||||
|
||||
func TestAnImageADefinitionPinsUnderAnOlderNameOfTheStoreIsNamedByADefinition(t *testing.T) {
|
||||
v := view(t, world())
|
||||
names := NamesOf("store:5000", oldName)
|
||||
defs := &Definitions{Pins: map[string][]Pin{}}
|
||||
for module, manifest := range map[string]string{
|
||||
// m-stranger: no record names it; the invoicing app pins it under the store's older name.
|
||||
"invoicing": container(oldName + "/app/server@" + d("m-stranger")),
|
||||
// m-old: the records say eligible; a definition pinning it does not change what the records say.
|
||||
"website": container("store:5000/app/server@" + d("m-old")),
|
||||
} {
|
||||
for _, p := range PinsIn(json.RawMessage(manifest), names) {
|
||||
defs.Pins[Key(p.Repository, p.Digest)] = append(defs.Pins[Key(p.Repository, p.Digest)], Pin{Module: module, Name: p.Name, Older: p.Older})
|
||||
}
|
||||
}
|
||||
got := stateByName(Classify(v, withFill(records()), defs))
|
||||
stranger := got["app/server "+d("m-stranger")]
|
||||
if stranger.State != StateNamedByDefinition {
|
||||
t.Fatalf("a manifest a definition pins under an older name reads %q, want %q", stranger.State, StateNamedByDefinition)
|
||||
}
|
||||
if want := []string{"a definition names it: invoicing, under " + oldName + ", an older name of the store"}; !reflect.DeepEqual(stranger.Why, want) {
|
||||
t.Errorf("why %q, want %q", stranger.Why, want)
|
||||
}
|
||||
if s := got["app/server "+d("m-old")].State; s != StateEligible {
|
||||
t.Errorf("a recorded manifest a definition pins reads %q: the records decide what the mesh made", s)
|
||||
}
|
||||
// Without the definitions it is what it was: unrecorded.
|
||||
if s := stateByName(Classify(v, withFill(records()), nil))["app/server "+d("m-stranger")].State; s != StateUnrecorded {
|
||||
t.Errorf("without definitions %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinsAreFoundInEveryStringAndOnlyUnderTheStoresNames(t *testing.T) {
|
||||
names := NamesOf("store:5000", oldName+", older.example/")
|
||||
manifest := `{"resources":[
|
||||
{"type":"container","image":"` + oldName + `/team/www@` + d("www") + `"},
|
||||
{"type":"file","content":"IMAGE=older.example/team/tool@` + d("tool") + `\n"},
|
||||
{"type":"container","image":"artifact-store://mod/art@` + d("built") + `"},
|
||||
{"type":"container","image":"store:5000/mod/art@` + d("addressed") + `"},
|
||||
{"type":"container","image":"not` + oldName + `/team/www@` + d("lookalike") + `"},
|
||||
{"type":"container","image":"docker.io/library/nginx@` + d("upstream") + `"},
|
||||
{"type":"container","image":"` + oldName + `/team/www:latest"}],
|
||||
"build":{"on":{"base":"` + oldName + `/team/www@` + d("www") + `"}}}`
|
||||
var got []string
|
||||
for _, p := range PinsIn(json.RawMessage(manifest), names) {
|
||||
got = append(got, fmt.Sprintf("%s@%s %s %v", p.Repository, p.Digest, p.Name, p.Older))
|
||||
}
|
||||
want := map[string]bool{
|
||||
"team/www@" + d("www") + " " + oldName + "/ true": true,
|
||||
"team/tool@" + d("tool") + " older.example/ true": true,
|
||||
"mod/art@" + d("built") + " artifact-store:// false": true,
|
||||
"mod/art@" + d("addressed") + " store:5000/ false": true,
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("found %d pins, want %d (one per image and name; not a longer host's tail, not another registry, not a tag): %v", len(got), len(want), got)
|
||||
}
|
||||
for _, g := range got {
|
||||
if !want[g] {
|
||||
t.Errorf("found %s", g)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPlatformTakesTheStateOfAnIndexADefinitionNames(t *testing.T) {
|
||||
v := mirrorWorld()
|
||||
defs := &Definitions{Pins: map[string][]Pin{
|
||||
Key("route-proxy/on-go_base", d("i-pre")): {{Module: "route-proxy", Name: oldName + "/", Older: true}},
|
||||
}}
|
||||
got := stateByName(Classify(v, withFill(mirrorRecords()), defs))
|
||||
for _, name := range []string{"i-pre", "p-pre"} {
|
||||
if s := got["route-proxy/on-go_base "+d(name)].State; s != StateNamedByDefinition {
|
||||
t.Errorf("%s reads %q, want %q", name, s, StateNamedByDefinition)
|
||||
}
|
||||
}
|
||||
if e := got["route-proxy/on-go_base "+d("p-pre")]; e.Index != d("i-pre") {
|
||||
t.Errorf("p-pre takes its state from %q", e.Index)
|
||||
}
|
||||
if s := got["route-proxy/on-go_base "+d("p-lost")].State; s != StateUnrecorded {
|
||||
t.Errorf("a platform nothing names reads %q", s)
|
||||
}
|
||||
for _, um := range UnrecordedMirrors(v, Classify(v, withFill(mirrorRecords()), defs)) {
|
||||
if strings.Contains(um.Reference, hexOf(d("i-pre"))) {
|
||||
t.Errorf("a copy a definition names is offered to the mirrors verb: %v", um)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// olderNames writes the file the mesh writes from the setting older-store-names.
|
||||
func olderNames(t *testing.T, names string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "store-names")
|
||||
if err := os.WriteFile(path, []byte("# written by the mesh\nolder="+names+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(NamesFileEnv, path)
|
||||
}
|
||||
|
||||
func TestTheOlderNamesAreReadFromTheFileTheMeshWrites(t *testing.T) {
|
||||
olderNames(t, oldName+" older.example")
|
||||
got, why := OlderNames()
|
||||
if why != "" || !reflect.DeepEqual(NamesOf("", got).Older, []string{oldName + "/", "older.example/"}) {
|
||||
t.Errorf("read %q (%s)", got, why)
|
||||
}
|
||||
olderNames(t, "none")
|
||||
if got, why := OlderNames(); why != "" || len(NamesOf("", got).Older) != 0 {
|
||||
t.Errorf("none read as %q (%s)", got, why)
|
||||
}
|
||||
t.Setenv(NamesFileEnv, filepath.Join(t.TempDir(), "absent"))
|
||||
if _, why := OlderNames(); why == "" {
|
||||
t.Error("an absent file was not said")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOlderNamesNotReadAreSaidFirst(t *testing.T) {
|
||||
t.Setenv(NamesFileEnv, "")
|
||||
a := &asked{definitions: map[string]string{"invoicing": container(oldName + "/app/server@" + d("m-stranger"))}}
|
||||
out, err := references(t, a)(map[string]any{"repository": "app/server"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := out.(map[string]any)
|
||||
if w := m["said"].([]string)[0]; !strings.Contains(w, NamesFileEnv) {
|
||||
t.Errorf("first said %q", w)
|
||||
}
|
||||
if s := manifestsOf(t, out)["app/server "+d("m-stranger")].State; s != StateUnrecorded {
|
||||
t.Errorf("m-stranger reads %q without the older names", s)
|
||||
}
|
||||
}
|
||||
|
||||
// references is one artifact_store_references tool, kept across calls as the runtime keeps the bundle.
|
||||
func references(t *testing.T, a *asked) func(map[string]any) (any, error) {
|
||||
f := world()
|
||||
s := &Store{Container: "mesh-registry", Run: f.run(t)}
|
||||
for _, tl := range Tools(s, Controller{Ask: a.ask}, &Catalogue{Ask: a.ask}) {
|
||||
if tl.Name == "artifact_store_references" {
|
||||
return tl.Run
|
||||
}
|
||||
}
|
||||
t.Fatal("no artifact_store_references")
|
||||
return nil
|
||||
}
|
||||
|
||||
func manifestsOf(t *testing.T, out any) map[string]Entry {
|
||||
t.Helper()
|
||||
entries, ok := out.(map[string]any)["manifests"].([]Entry)
|
||||
if !ok {
|
||||
t.Fatalf("no manifests in %v", out)
|
||||
}
|
||||
return stateByName(entries)
|
||||
}
|
||||
|
||||
func TestReferencesReadTheDefinitionsUnderTheStoresOlderNames(t *testing.T) {
|
||||
olderNames(t, oldName)
|
||||
a := &asked{definitions: map[string]string{
|
||||
"invoicing": container(oldName + "/app/server@" + d("m-stranger")),
|
||||
"grafana": container("grafana/grafana@" + d("upstream")),
|
||||
}}
|
||||
run := references(t, a)
|
||||
out, err := run(map[string]any{"repository": "app/server"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := manifestsOf(t, out)["app/server "+d("m-stranger")]
|
||||
if e.State != StateNamedByDefinition || len(e.Why) != 1 || !strings.Contains(e.Why[0], "invoicing") || !strings.Contains(e.Why[0], oldName) {
|
||||
t.Errorf("m-stranger: %+v", e)
|
||||
}
|
||||
said := out.(map[string]any)["definitions"].(map[string]any)
|
||||
if said["read"] != 2 || !reflect.DeepEqual(said["older_store_names"], []string{oldName}) ||
|
||||
!reflect.DeepEqual(said["store_names"], []string{"artifact-store://", "store:5000"}) {
|
||||
t.Errorf("definitions said %v", said)
|
||||
}
|
||||
if w := out.(map[string]any)["said"].([]string)[0]; strings.Contains(w, "could not be read") {
|
||||
t.Errorf("a warning with every definition read: %s", w)
|
||||
}
|
||||
// A definition read at its commit is kept: the next call asks only for the list.
|
||||
before := 0
|
||||
for _, c := range a.calls {
|
||||
if c == CatalogueModule {
|
||||
before++
|
||||
}
|
||||
}
|
||||
if _, err := run(map[string]any{"repository": "app/server"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after := 0
|
||||
for _, c := range a.calls {
|
||||
if c == CatalogueModule {
|
||||
after++
|
||||
}
|
||||
}
|
||||
if before != 2 || after != 2 {
|
||||
t.Errorf("asked catalog_module %d times, then %d: each definition at its commit is read once", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADefinitionNotReadIsSaidFirstAndLeavesWhatItPinsUnrecorded(t *testing.T) {
|
||||
olderNames(t, oldName)
|
||||
a := &asked{
|
||||
definitions: map[string]string{"invoicing": container(oldName + "/app/server@" + d("m-stranger")), "grafana": container("x")},
|
||||
failing: map[string]bool{"invoicing": true},
|
||||
}
|
||||
out, err := references(t, a)(map[string]any{"repository": "app/server"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := out.(map[string]any)
|
||||
if s := manifestsOf(t, out)["app/server "+d("m-stranger")].State; s != StateUnrecorded {
|
||||
t.Errorf("m-stranger reads %q with its definition unread", s)
|
||||
}
|
||||
if w := m["said"].([]string)[0]; !strings.Contains(w, "1 definitions could not be read") {
|
||||
t.Errorf("first said %q", w)
|
||||
}
|
||||
if u := m["definitions"].(map[string]any)["unread"].(map[string]string); u["invoicing"] == "" || len(u) != 1 {
|
||||
t.Errorf("unread %v", u)
|
||||
}
|
||||
|
||||
// The catalogue not answering at all: the records are still answered, and the warning stands first.
|
||||
a = &asked{noCatalogue: true}
|
||||
out, err = references(t, a)(map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m = out.(map[string]any)
|
||||
if w := m["said"].([]string)[0]; !strings.Contains(w, "the definitions could not be read") || !strings.Contains(w, "no responders") {
|
||||
t.Errorf("first said %q", w)
|
||||
}
|
||||
if m["definitions"].(map[string]any)["not_asked"] == nil {
|
||||
t.Errorf("definitions %v", m["definitions"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoCatalogueIsSaidAndNothingIsAsked(t *testing.T) {
|
||||
defs := (&Catalogue{}).Read(context.Background(), NamesOf("", ""))
|
||||
if defs.NotAsked == "" || defs.Read != 0 {
|
||||
t.Errorf("%+v", defs)
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ func main() {
|
||||
UID: os.Getuid(),
|
||||
}
|
||||
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): artifact-store-tools.
|
||||
if err := stdio.Serve("", Tools(s, Controller{Ask: stdio.Ask}, &Catalogue{Ask: stdio.Ask})); err != nil {
|
||||
if err := stdio.Serve("", Tools(s, Controller{Ask: stdio.Ask})); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ func TestAPlatformManifestTakesTheStateOfTheIndexNamingIt(t *testing.T) {
|
||||
for i := range recs.References {
|
||||
fill(&recs.References[i])
|
||||
}
|
||||
got := stateByName(Classify(v, recs, nil))
|
||||
got := stateByName(Classify(v, recs))
|
||||
up := "upstream/docker.io/library/golang "
|
||||
for name, want := range map[string]string{
|
||||
up + d("p-amd"): StateEligible, // only the eligible index names it
|
||||
@@ -92,7 +92,7 @@ func TestAPlatformManifestTakesTheStateOfTheIndexNamingIt(t *testing.T) {
|
||||
if len(mirrors) != 2 || !got["route-proxy/on-go_base "+d("i-pre")].Mirror || !got["route-proxy/on-go_base "+d("p-lost")].Mirror {
|
||||
t.Errorf("offered as unrecorded copies: %v", mirrors)
|
||||
}
|
||||
um := UnrecordedMirrors(v, Classify(v, recs, nil))
|
||||
um := UnrecordedMirrors(v, Classify(v, recs))
|
||||
if len(um) != 2 || um[0].Reference != "artifact-store://route-proxy/on-go_base@"+d("i-pre") || um[0].OnlyTheir != 1+1+1000 {
|
||||
t.Errorf("unrecorded copies: %+v", um)
|
||||
}
|
||||
@@ -104,7 +104,7 @@ func TestLettingAnIndexGoTakesItsOwnPlatformsAndSaysWhatStaysAndNamesOne(t *test
|
||||
for i := range recs.References {
|
||||
fill(&recs.References[i])
|
||||
}
|
||||
entries := Classify(v, recs, nil)
|
||||
entries := Classify(v, recs)
|
||||
old := "artifact-store://upstream/docker.io/library/golang@" + d("i-old")
|
||||
gone := LetGoKeys(v, []string{old}, Spared(v, entries))
|
||||
up := "upstream/docker.io/library/golang"
|
||||
|
||||
@@ -215,7 +215,7 @@ func TestTheMarkIsTheCollectorsIncludingAnIndexAndSharedBlobs(t *testing.T) {
|
||||
func TestEveryStateIsGiven(t *testing.T) {
|
||||
v := view(t, world())
|
||||
got := map[string]string{}
|
||||
for _, e := range Classify(v, withFill(records()), nil) {
|
||||
for _, e := range Classify(v, withFill(records())) {
|
||||
got[e.Repository+" "+e.Digest] = e.State
|
||||
}
|
||||
want := map[string]string{
|
||||
@@ -239,7 +239,7 @@ func TestALetGoReferenceStillPresentIsSaidSo(t *testing.T) {
|
||||
v := view(t, world())
|
||||
r := records()
|
||||
r.References = append(r.References, Record{Reference: "artifact-store://app/server@" + d("m-stranger"), State: "collected"})
|
||||
for _, e := range Classify(v, withFill(r), nil) {
|
||||
for _, e := range Classify(v, withFill(r)) {
|
||||
if e.Digest == d("m-stranger") && e.State != StateCollectedPresent {
|
||||
t.Errorf("state %s", e.State)
|
||||
}
|
||||
@@ -270,7 +270,7 @@ func TestBytesFreedCountASharedBlobAsKept(t *testing.T) {
|
||||
|
||||
func TestOnlyTheirBytesLeaveOutWhatAnotherStateMarks(t *testing.T) {
|
||||
v := view(t, world())
|
||||
entries := Classify(v, withFill(records()), nil)
|
||||
entries := Classify(v, withFill(records()))
|
||||
sums := Summarise(v, entries, entries)
|
||||
el := sums[StateEligible]
|
||||
if el.OnlyTheirBytes != 500+v.L.Blobs[d("m-old")] {
|
||||
@@ -284,11 +284,6 @@ type asked struct {
|
||||
calls []string
|
||||
args []map[string]any
|
||||
fail map[string]bool
|
||||
// definitions are the catalogue's modules and their manifests; failing, the modules whose
|
||||
// definition cannot be read; noCatalogue, the catalogue not answering at all.
|
||||
definitions map[string]string
|
||||
failing map[string]bool
|
||||
noCatalogue bool
|
||||
}
|
||||
|
||||
func (a *asked) ask(key string, body any) (json.RawMessage, error) {
|
||||
@@ -304,31 +299,6 @@ func (a *asked) ask(key string, body any) (json.RawMessage, error) {
|
||||
switch key {
|
||||
case "seat:mesh-controller.artifacts":
|
||||
answer = records()
|
||||
case CatalogueModules:
|
||||
if a.noCatalogue {
|
||||
return nil, fmt.Errorf("no responders")
|
||||
}
|
||||
ms := []map[string]any{}
|
||||
for name := range a.definitions {
|
||||
ms = append(ms, map[string]any{"module": name, "commit": "c-" + name, "path": "modules/" + name})
|
||||
}
|
||||
answer = map[string]any{"modules": ms}
|
||||
case CatalogueModule:
|
||||
name, _ := args["module"].(string)
|
||||
if a.failing[name] {
|
||||
return nil, fmt.Errorf("no answer")
|
||||
}
|
||||
if args["commit"] != "c-"+name {
|
||||
return nil, fmt.Errorf("asked %s at %v", name, args["commit"])
|
||||
}
|
||||
manifest, has := a.definitions[name]
|
||||
if !has {
|
||||
answer = map[string]any{"error": "the catalogue holds no " + name}
|
||||
break
|
||||
}
|
||||
// The catalogue's tool answers through the runtime as the protocol's content list.
|
||||
raw, _ := json.Marshal(map[string]any{"module": map[string]any{"module": name, "manifest": json.RawMessage(manifest)}})
|
||||
return json.Marshal(map[string]any{"content": []map[string]any{{"type": "text", "text": string(raw)}}})
|
||||
case "seat:mesh-controller.collect":
|
||||
if args["confirm"] == "true" {
|
||||
answer = CollectAnswer{LetGo: []string{"artifact-store://app/server@" + d("m-old")}}
|
||||
@@ -345,7 +315,7 @@ func (a *asked) ask(key string, body any) (json.RawMessage, error) {
|
||||
func tool(t *testing.T, a *asked, name string) func(map[string]any) (any, error) {
|
||||
f := world()
|
||||
s := &Store{Container: "mesh-registry", Run: f.run(t)}
|
||||
for _, tl := range Tools(s, Controller{Ask: a.ask}, &Catalogue{Ask: a.ask}) {
|
||||
for _, tl := range Tools(s, Controller{Ask: a.ask}) {
|
||||
if tl.Name == name {
|
||||
return tl.Run
|
||||
}
|
||||
@@ -416,7 +386,7 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var served []string
|
||||
for _, tl := range Tools(&Store{}, Controller{}, &Catalogue{}) {
|
||||
for _, tl := range Tools(&Store{}, Controller{}) {
|
||||
if !strings.HasPrefix(tl.Name, "artifact_store_") || tl.Description == "" || tl.Run == nil {
|
||||
t.Errorf("tool %q", tl.Name)
|
||||
}
|
||||
@@ -428,7 +398,7 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
|
||||
if !reflect.DeepEqual(served, listed) {
|
||||
t.Fatalf("served %v, manifest %v", served, listed)
|
||||
}
|
||||
want := []string{"seat:mesh-controller.artifacts", "seat:mesh-controller.collect", CatalogueModules, CatalogueModule}
|
||||
want := []string{"seat:mesh-controller.artifacts", "seat:mesh-controller.collect"}
|
||||
if !reflect.DeepEqual(m.Invokes, want) {
|
||||
t.Errorf("invokes %v", m.Invokes)
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The module's directory, where its manifest and its files/ are.
|
||||
const moduleDir = "../.."
|
||||
|
||||
// templateText is the text a file resource places on the machine: its template's, in the module's files/
|
||||
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
|
||||
func templateText(t *testing.T, r map[string]any) string {
|
||||
t.Helper()
|
||||
name, _ := r["template"].(string)
|
||||
if name == "" {
|
||||
t.Fatalf("resource %v names no template", r["id"])
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// No file resource carries its text inline: each names its template in files/, and the build packs files/
|
||||
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
|
||||
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if _, inline := r["content"]; inline {
|
||||
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
|
||||
}
|
||||
if r["type"] == "file" {
|
||||
templateText(t, r)
|
||||
}
|
||||
}
|
||||
packs := 0
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "archive" && a["from"] == "files" {
|
||||
packs++
|
||||
if a["name"] != "files" {
|
||||
t.Errorf("the templates archive is named %v, not files", a["name"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if packs != 1 {
|
||||
t.Errorf("the build packs files/ into %d archives, not one", packs)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
|
||||
// Tools are the artifact store's tools (novox/hq ADR 0251 §1). The first three only read; artifact_store_collect
|
||||
// only reads too, unless it is a real run, and then it asks the controller, which decides and deletes.
|
||||
func Tools(s *Store, c Controller, cat *Catalogue) []stdio.Tool {
|
||||
func Tools(s *Store, c Controller) []stdio.Tool {
|
||||
ctx := context.Background()
|
||||
repoArg := map[string]any{"type": "string", "description": "one repository, as the store names it (<module>/<artifact>)"}
|
||||
return []stdio.Tool{
|
||||
@@ -53,9 +53,7 @@ func Tools(s *Store, c Controller, cat *Catalogue) []stdio.Tool {
|
||||
{
|
||||
Name: "artifact_store_references",
|
||||
Description: "What the controller's records say of each manifest the artifact store holds: kept (a definition " +
|
||||
"names it, or one of the five most recent builds of a module the mesh holds), named by a definition (no " +
|
||||
"record names it, and a definition in the catalogue pins it by digest, under the store's current name or " +
|
||||
"an older one: the module is said), the holder of a kept archive, " +
|
||||
"names it, or one of the five most recent builds of a module the mesh holds), the holder of a kept archive, " +
|
||||
"eligible (the mesh made it and keeps it for no reason), the holder of an eligible archive, let go yet present, " +
|
||||
"a named document the controller keeps under a tag, or unrecorded — no record names it. A platform manifest " +
|
||||
"takes the state of the recorded index naming it. Counted and sized by " +
|
||||
@@ -69,19 +67,11 @@ func Tools(s *Store, c Controller, cat *Catalogue) []stdio.Tool {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The definitions are read while the store is: each bounded below the runtime's call limit.
|
||||
read := make(chan *Definitions, 1)
|
||||
go func() {
|
||||
older, why := OlderNames()
|
||||
defs := cat.Read(ctx, NamesOf(recs.Store, older))
|
||||
defs.NamesNotRead = why
|
||||
read <- defs
|
||||
}()
|
||||
v, err := s.View(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return References(v, recs, <-read, repo)
|
||||
return References(v, recs, repo)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -126,7 +116,7 @@ func Tools(s *Store, c Controller, cat *Catalogue) []stdio.Tool {
|
||||
if err != nil {
|
||||
return map[string]any{"controller": answer, "bytes_not_worked_out": "the records could not be read: " + err.Error()}, nil
|
||||
}
|
||||
return Collected(v, answer, dry, Classify(v, recs, nil)), nil
|
||||
return Collected(v, answer, dry, Classify(v, recs)), nil
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -235,11 +225,11 @@ func Usage(v *View, top int) map[string]any {
|
||||
}
|
||||
|
||||
// References answers artifact_store_references.
|
||||
func References(v *View, recs *Records, defs *Definitions, only string) (map[string]any, error) {
|
||||
func References(v *View, recs *Records, only string) (map[string]any, error) {
|
||||
if only != "" && v.L.Repos[only] == nil {
|
||||
return nil, fmt.Errorf("the store holds no repository %q", only)
|
||||
}
|
||||
entries := Classify(v, recs, defs)
|
||||
entries := Classify(v, recs)
|
||||
byRepo := map[string][]Entry{}
|
||||
for _, e := range entries {
|
||||
byRepo[e.Repository] = append(byRepo[e.Repository], e)
|
||||
@@ -253,7 +243,7 @@ func References(v *View, recs *Records, defs *Definitions, only string) (map[str
|
||||
}
|
||||
whole := Summarise(v, entries, entries)
|
||||
said := []string{}
|
||||
for _, state := range []string{StateKept, StateNamedByDefinition, StateHolderKept, StateEligible, StateHolderEligible, StateCollectedPresent, StateNamedDocument, StateUnrecorded} {
|
||||
for _, state := range []string{StateKept, StateHolderKept, StateEligible, StateHolderEligible, StateCollectedPresent, StateNamedDocument, StateUnrecorded} {
|
||||
if s := whole[state]; s != nil {
|
||||
said = append(said, fmt.Sprintf("%s: %d manifests marking %s, %s of it marked by nothing else", state, s.Manifests, human(s.Bytes), human(s.OnlyTheirBytes)))
|
||||
}
|
||||
@@ -286,57 +276,9 @@ func References(v *View, recs *Records, defs *Definitions, only string) (map[str
|
||||
if u := unreadNote(v); u != nil {
|
||||
out["unread"] = u
|
||||
}
|
||||
out["definitions"] = definitionsSaid(defs)
|
||||
if warn := definitionsWarning(defs); warn != "" {
|
||||
// Said first: what it leaves out is a manifest a machine may run, read as unrecorded.
|
||||
out["said"] = append([]string{warn}, said...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// definitionsSaid is how the definitions were read: how many, under which names, and what was not read.
|
||||
func definitionsSaid(defs *Definitions) map[string]any {
|
||||
if defs == nil {
|
||||
return map[string]any{"read": 0, "not_asked": "the definitions were not read"}
|
||||
}
|
||||
var current, older []string
|
||||
for _, n := range defs.Names.Current {
|
||||
current = append(current, shownName(n))
|
||||
}
|
||||
for _, n := range defs.Names.Older {
|
||||
older = append(older, shownName(n))
|
||||
}
|
||||
out := map[string]any{"read": defs.Read, "store_names": nonNil(current), "older_store_names": nonNil(older)}
|
||||
if defs.NotAsked != "" {
|
||||
out["not_asked"] = defs.NotAsked
|
||||
}
|
||||
if defs.NamesNotRead != "" {
|
||||
out["older_store_names_not_read"] = defs.NamesNotRead
|
||||
}
|
||||
if len(defs.Unread) > 0 {
|
||||
out["unread"] = defs.Unread
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// definitionsWarning says, when any definition was not read, that a manifest it pins reads as unrecorded.
|
||||
func definitionsWarning(defs *Definitions) string {
|
||||
switch {
|
||||
case defs == nil:
|
||||
return ""
|
||||
case defs.NotAsked != "":
|
||||
return "the definitions could not be read (" + defs.NotAsked + "), so a manifest a definition pins " +
|
||||
"and no record names reads as unrecorded here: ask again before reading unrecorded as unused"
|
||||
case defs.NamesNotRead != "":
|
||||
return defs.NamesNotRead + ", so a manifest a definition pins under an older name of the store and no " +
|
||||
"record names reads as unrecorded here"
|
||||
case len(defs.Unread) > 0:
|
||||
return fmt.Sprintf("%d definitions could not be read (listed in definitions.unread), so a manifest one "+
|
||||
"of them pins and no record names reads as unrecorded here: ask again — what was read is kept", len(defs.Unread))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Collected answers artifact_store_collect: the controller's answer, and what the store's collector frees.
|
||||
func Collected(v *View, a *CollectAnswer, dry bool, entries []Entry) map[string]any {
|
||||
refs := a.LetGo
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# The names the artifact store had before, from the setting older-store-names, written by the mesh.
|
||||
# artifact_store_references reads every definition under these too (novox/hq issue 323).
|
||||
# Replaced on every push; change the setting, never this file.
|
||||
older=${config:older-store-names}
|
||||
@@ -3,40 +3,13 @@
|
||||
"version": "1",
|
||||
"invokes": [
|
||||
"seat:mesh-controller.artifacts",
|
||||
"seat:mesh-controller.collect",
|
||||
"mesh-catalog.catalog_modules",
|
||||
"mesh-catalog.catalog_module"
|
||||
"seat:mesh-controller.collect"
|
||||
],
|
||||
"tools": [
|
||||
"artifact_store_repositories",
|
||||
"artifact_store_usage",
|
||||
"artifact_store_references",
|
||||
"artifact_store_collect"
|
||||
],
|
||||
"config": {
|
||||
"older-store-names": {
|
||||
"type": "string",
|
||||
"description": "The names the artifact store had before, as host names or host:port separated by spaces, or none when it had no other. artifact_store_references reads every definition under these as well as under the store's current name, so an image a definition pins under one is said to be named by a definition (novox/hq issue 323).",
|
||||
"pattern": "none|[A-Za-z0-9.:-]+( [A-Za-z0-9.:-]+)*",
|
||||
"default": "none"
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "store-names",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/store-names",
|
||||
"mode": "0600",
|
||||
"trusted": false,
|
||||
"template": "files/store-names"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
@@ -51,14 +24,8 @@
|
||||
"artifact-store-tools"
|
||||
],
|
||||
"env": {
|
||||
"MESH_ARTIFACT_STORE_CONTAINER": "mesh-registry",
|
||||
"MESH_ARTIFACT_STORE_NAMES_FILE": "${dir:state}/store-names"
|
||||
"MESH_ARTIFACT_STORE_CONTAINER": "mesh-registry"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -385,15 +385,3 @@ The module no longer writes a file into i3's `config.d`. Its window-manager line
|
||||
under `files/i3/` where it had one) are a contribution to `node-display-session`. The i3 module places
|
||||
them in its own configuration under a `# <module>` line, so this module depends on a window manager
|
||||
being assigned beside it.
|
||||
|
||||
## Files it does not know
|
||||
|
||||
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
|
||||
|
||||
- `/usr/local/lib/asus-zephyrus-g14`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `/etc/systemd/system/systemd-suspend.service.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `/etc/systemd/system/systemd-hibernate.service.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `/etc/systemd/system/systemd-suspend-then-hibernate.service.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `/etc/systemd/system/nvidia-powerd.service.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `/etc/UPower/UPower.conf.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `/etc/X11/xorg.conf.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
|
||||
@@ -34,9 +34,11 @@ func TestKeysListsTriggersI3LinesAndFirmwareKeys(t *testing.T) {
|
||||
home := "/home/operator"
|
||||
t.Setenv("MESH_OPERATOR_HOME", home)
|
||||
// The i3 module's file as the controller composes it: other modules' lines around this one's.
|
||||
own := m.i3Lines()
|
||||
if own == "" || len(m.Contributions) != 0 {
|
||||
t.Fatalf("the lines for the window manager are one item, and no contribution is left: %q %v", own, m.Contributions)
|
||||
var own string
|
||||
for _, c := range m.Contributions {
|
||||
if c.Seat == "node-display-session" {
|
||||
own += c.Content
|
||||
}
|
||||
}
|
||||
put(t, f.root, home+"/"+I3Config, "set $mod Mod4\n# rofi\nbindsym $mod+d exec rofi\n# "+ModuleName+"\n"+own+
|
||||
"# triggerhappy-not-here\nbindsym $mod+Shift+z exec other\n#########################################\ninclude x\n")
|
||||
|
||||
@@ -15,41 +15,19 @@ type manifest struct {
|
||||
Tools []string `json:"tools"`
|
||||
Emits []string `json:"emits"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
// Items of collections (novox/hq ADR 0349 clause 13): the vendor keys are an item of the node-hotkeys
|
||||
// triggers, its text the template file beside the manifest.
|
||||
Items []struct {
|
||||
Collection string `json:"collection"`
|
||||
Template string `json:"template"`
|
||||
} `json:"items"`
|
||||
// Old-form contributions to other modules' seats (novox/hq ADR 0212): none is left.
|
||||
Contributions []any `json:"contributions"`
|
||||
// Contributions to other modules' seats (novox/hq ADR 0212): the vendor keys go to node-hotkeys.
|
||||
Contributions []struct {
|
||||
Seat string `json:"seat"`
|
||||
Kind string `json:"kind"`
|
||||
Content string `json:"content"`
|
||||
} `json:"contributions"`
|
||||
}
|
||||
|
||||
// i3Lines is the module's lines for the window manager: the text of the template of its item of the collection
|
||||
// contribution:node-display-session:config (design 57 L3, issue 848).
|
||||
func (m manifest) i3Lines() string {
|
||||
for _, it := range m.Items {
|
||||
if it.Collection == "contribution:node-display-session:config" {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", it.Template))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// triggers is the module's contribution to node-hotkeys: its vendor keys, the text of the template of its
|
||||
// item of the collection contribution:node-hotkeys:trigger.
|
||||
// triggers is the module's contribution to node-hotkeys: its vendor keys.
|
||||
func (m manifest) triggers() string {
|
||||
for _, it := range m.Items {
|
||||
if it.Collection == "contribution:node-hotkeys:trigger" {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", it.Template))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(raw)
|
||||
for _, c := range m.Contributions {
|
||||
if c.Seat == "node-hotkeys" && c.Kind == "trigger" {
|
||||
return c.Content
|
||||
}
|
||||
}
|
||||
return ""
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Each file's text is a template in templates/, the directory the manifest names (novox/hq ADR 0349 §2, issue 731),
|
||||
// never in files/: files/ is the archive the machine unpacks, and stays exactly what the machine is given.
|
||||
func TestEveryFileTakesItsTextFromATemplateOutsideTheUnpackedFiles(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Templates string `json:"templates"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.Templates != "templates" {
|
||||
t.Fatalf("the manifest names the templates directory %q, not templates", m.Templates)
|
||||
}
|
||||
files := 0
|
||||
for _, r := range m.Resources {
|
||||
if _, inline := r["content"]; inline {
|
||||
t.Errorf("resource %v keeps its text inline: keep it in templates/ and name it as the template", r["id"])
|
||||
}
|
||||
if r["type"] != "file" {
|
||||
continue
|
||||
}
|
||||
files++
|
||||
name, _ := r["template"].(string)
|
||||
if !strings.HasPrefix(name, "templates/") {
|
||||
t.Errorf("resource %v names the template %q, not one in templates/", r["id"], name)
|
||||
continue
|
||||
}
|
||||
if st, err := os.Lstat(filepath.Join("../..", name)); err != nil || !st.Mode().IsRegular() {
|
||||
t.Errorf("resource %v names the template %s, which is not a plain file: %v", r["id"], name, err)
|
||||
}
|
||||
}
|
||||
if files == 0 {
|
||||
t.Fatal("no file resource")
|
||||
}
|
||||
packs := 0
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "archive" && a["from"] == "templates" {
|
||||
packs++
|
||||
if a["name"] != "templates" {
|
||||
t.Errorf("the templates archive is named %v, not templates", a["name"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if packs != 1 {
|
||||
t.Fatalf("the build packs templates/ into %d archives, not one", packs)
|
||||
}
|
||||
// The templates archive is read by the controller, never unpacked onto the machine.
|
||||
for _, r := range m.Resources {
|
||||
if r["artifact"] == "templates" {
|
||||
t.Errorf("resource %v places the templates archive on the machine", r["id"])
|
||||
}
|
||||
}
|
||||
// And files/ holds what the machine runs, no template.
|
||||
err = filepath.WalkDir("../../files", func(at string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() && !strings.HasPrefix(filepath.ToSlash(at), "../../files/bin/") {
|
||||
t.Errorf("files/ holds %s, which is not one of the scripts the machine unpacks", at)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"module": "asus-zephyrus-g14",
|
||||
"version": "1",
|
||||
"moved": true,
|
||||
"templates": "templates",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"service-manager"
|
||||
@@ -54,85 +52,80 @@
|
||||
"id": "scripts",
|
||||
"type": "archive",
|
||||
"path": "/usr/local/lib/asus-zephyrus-g14",
|
||||
"artifact": "scripts",
|
||||
"unknown": "program"
|
||||
"artifact": "scripts"
|
||||
},
|
||||
{
|
||||
"id": "nvidia-options",
|
||||
"type": "file",
|
||||
"path": "/etc/modprobe.d/g14-nvidia-power.conf",
|
||||
"mode": "0644",
|
||||
"template": "templates/modprobe.d/g14-nvidia-power.conf"
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The discrete GPU's driver options on the ROG Zephyrus G14 (GA403, RTX 40 series, hybrid graphics).\n#\n# NVreg_DynamicPowerManagement=0x00 turns runtime D3 off. With it on, a change of power source sends\n# the driver an ACPI notification it fails to handle on this model (\"RmHandleDNotifierEvent: Failed to\n# handle ACPI D-Notifier event, status=0x62\"), and the GPU stops making progress until the machine is\n# powered off. Off costs a few idle watts in hybrid mode and keeps the machine up.\n#\n# NVreg_PreserveVideoMemoryAllocations=1 saves video memory across suspend, so what used the GPU still\n# works after waking. It needs nvidia-suspend, -hibernate and -resume to run around a sleep, which this\n# module's drop-ins on the sleep services ask for.\n#\n# A change here applies when the driver next loads: at the next boot.\noptions nvidia NVreg_PreserveVideoMemoryAllocations=1\noptions nvidia NVreg_DynamicPowerManagement=0x00\n"
|
||||
},
|
||||
{
|
||||
"id": "video-options",
|
||||
"type": "file",
|
||||
"path": "/etc/modprobe.d/video-brightness-switch.conf",
|
||||
"mode": "0644",
|
||||
"template": "templates/modprobe.d/video-brightness-switch.conf"
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The ACPI video driver does not change the backlight itself on the brightness keys: on this model it\n# moves the wrong one. The keys are triggerhappy's (see /etc/triggerhappy/triggers.d/asus-g14.conf).\n# Applies when the module next loads: at the next boot.\noptions video brightness_switch_enabled=0\n"
|
||||
},
|
||||
{
|
||||
"id": "suspend-drop-ins",
|
||||
"type": "directory",
|
||||
"path": "/etc/systemd/system/systemd-suspend.service.d",
|
||||
"mode": "0755",
|
||||
"unknown": "program"
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "nvidia-on-suspend",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf",
|
||||
"mode": "0644",
|
||||
"template": "templates/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf"
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-suspend.service nvidia-resume.service\n"
|
||||
},
|
||||
{
|
||||
"id": "hibernate-drop-ins",
|
||||
"type": "directory",
|
||||
"path": "/etc/systemd/system/systemd-hibernate.service.d",
|
||||
"mode": "0755",
|
||||
"unknown": "program"
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "nvidia-on-hibernate",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf",
|
||||
"mode": "0644",
|
||||
"template": "templates/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf"
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-hibernate.service nvidia-resume.service\n"
|
||||
},
|
||||
{
|
||||
"id": "suspend-then-hibernate-drop-ins",
|
||||
"type": "directory",
|
||||
"path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d",
|
||||
"mode": "0755",
|
||||
"unknown": "program"
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "nvidia-on-suspend-then-hibernate",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf",
|
||||
"mode": "0644",
|
||||
"template": "templates/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf"
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-suspend-then-hibernate.service nvidia-resume.service\n"
|
||||
},
|
||||
{
|
||||
"id": "powerd-drop-ins",
|
||||
"type": "directory",
|
||||
"path": "/etc/systemd/system/nvidia-powerd.service.d",
|
||||
"mode": "0755",
|
||||
"unknown": "program"
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "powerd-opt-in",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf",
|
||||
"mode": "0644",
|
||||
"template": "templates/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf"
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# nvidia-powerd (Dynamic Boost) was the first error in the chain that hung this model's GPU on a change\n# of power source, and asusd starts it on mains. It runs only when the kernel command line says\n# zephyrus.nvidia-powerd — an explicit opt-in, at boot.\n[Unit]\nConditionKernelCommandLine=zephyrus.nvidia-powerd\n"
|
||||
},
|
||||
{
|
||||
"id": "backlight-rule",
|
||||
"type": "file",
|
||||
"path": "/etc/udev/rules.d/90-backlight.rules",
|
||||
"mode": "0644",
|
||||
"template": "templates/udev/rules.d/90-backlight.rules"
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The backlights are writable by the video group, so the brightness keys and the module's brightness tool\n# move the panel without root.\nACTION==\"add\", SUBSYSTEM==\"backlight\", RUN+=\"/usr/bin/chgrp video /sys/class/backlight/%k/brightness\", RUN+=\"/usr/bin/chmod g+w /sys/class/backlight/%k/brightness\"\n"
|
||||
},
|
||||
{
|
||||
"id": "udev",
|
||||
@@ -151,15 +144,14 @@
|
||||
"id": "upower-drop-ins",
|
||||
"type": "directory",
|
||||
"path": "/etc/UPower/UPower.conf.d",
|
||||
"mode": "0755",
|
||||
"unknown": "program"
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "low-battery",
|
||||
"type": "file",
|
||||
"path": "/etc/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf",
|
||||
"mode": "0644",
|
||||
"template": "templates/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf"
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# On low battery the machine suspends rather than powering off, at 7 % — s2idle still draws a little,\n# so it leaves headroom. A drop-in over the package's own UPower.conf, which stays the package's.\n[UPower]\nUsePercentageForPolicy=true\nPercentageLow=15.0\nPercentageCritical=10.0\nPercentageAction=7.0\nCriticalPowerAction=Suspend\nAllowRiskyCriticalPowerAction=true\n"
|
||||
},
|
||||
{
|
||||
"id": "upower",
|
||||
@@ -175,30 +167,14 @@
|
||||
"id": "xorg-drop-ins",
|
||||
"type": "directory",
|
||||
"path": "/etc/X11/xorg.conf.d",
|
||||
"mode": "0755",
|
||||
"unknown": "program"
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "touchpad",
|
||||
"type": "file",
|
||||
"path": "/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf",
|
||||
"mode": "0644",
|
||||
"template": "templates/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf"
|
||||
}
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"collection": "shell:after-wake:normal",
|
||||
"template": "templates/after-wake"
|
||||
},
|
||||
{
|
||||
"collection": "contribution:node-hotkeys:trigger",
|
||||
"template": "templates/hotkeys-triggers"
|
||||
},
|
||||
{
|
||||
"collection": "contribution:node-display-session:config",
|
||||
"name": "display-session",
|
||||
"template": "templates/display-session"
|
||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The touchpad's settings, applied by X every time the device appears — at login and after every\n# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran\n# xinput after a resume as a named person on a guessed display.\nSection \"InputClass\"\n Identifier \"asus-zephyrus-g14 touchpad\"\n MatchIsTouchpad \"on\"\n Option \"Tapping\" \"on\"\n Option \"NaturalScrolling\" \"true\"\n Option \"AccelSpeed\" \"0.15\"\nEndSection\n"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
@@ -218,12 +194,31 @@
|
||||
"name": "scripts",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
},
|
||||
{
|
||||
"name": "templates",
|
||||
"kind": "archive",
|
||||
"from": "templates"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"contributions": [
|
||||
{
|
||||
"seat": "node-hotkeys",
|
||||
"kind": "trigger",
|
||||
"content": "# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness,\n# and the touchpad key. Each runs this module's own script, as the operator's account.\nKEY_PROG1\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause\nKEY_PROG3\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous\nKEY_PROG4\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next\nKEY_BRIGHTNESSDOWN\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSDOWN\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSUP\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_BRIGHTNESSUP\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_F21\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n"
|
||||
},
|
||||
{
|
||||
"seat": "node-display-session",
|
||||
"kind": "config",
|
||||
"content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are\n# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for\n# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's.\n\n# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the\n# screenshot grabs the pointer to select a region, which fails while the key is still held.\nbindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh\n\n# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the\n# external output.\nbindsym $mod+p exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display order\n\n# The keyboard backlight's level, shown when it changes.\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-kbd-notify\n"
|
||||
},
|
||||
{
|
||||
"seat": "node-display-session",
|
||||
"kind": "config",
|
||||
"content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The model's panel and touchpad.\n\n# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where\n# is the display server's (autorandr, run at every session start).\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display primary\n\n# The touchpad's settings again, by hand. X applies them itself whenever the device appears.\nbindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n"
|
||||
}
|
||||
],
|
||||
"shell": [
|
||||
{
|
||||
"for": "after-wake",
|
||||
"slot": "normal",
|
||||
"code": "# The touchpad's settings again after waking, in the operator's session: X applies the module's\n# input class when the device appears, and this covers a wake that does not initialise it again.\n# Runs as root from the power module; the reset itself runs as the session's owner.\nsleep 2\nowner=$(ps -o user= -C i3 | head -n 1)\n[ -n \"$owner\" ] && runuser -u \"$owner\" -- /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\ntrue\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||
#
|
||||
# On low battery the machine suspends rather than powering off, at 7 % — s2idle still draws a little,
|
||||
# so it leaves headroom. A drop-in over the package's own UPower.conf, which stays the package's.
|
||||
[UPower]
|
||||
UsePercentageForPolicy=true
|
||||
PercentageLow=15.0
|
||||
PercentageCritical=10.0
|
||||
PercentageAction=7.0
|
||||
CriticalPowerAction=Suspend
|
||||
AllowRiskyCriticalPowerAction=true
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||
#
|
||||
# The touchpad's settings, applied by X every time the device appears — at login and after every
|
||||
# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran
|
||||
# xinput after a resume as a named person on a guessed display.
|
||||
Section "InputClass"
|
||||
Identifier "asus-zephyrus-g14 touchpad"
|
||||
MatchIsTouchpad "on"
|
||||
Option "Tapping" "on"
|
||||
Option "NaturalScrolling" "true"
|
||||
Option "AccelSpeed" "0.15"
|
||||
EndSection
|
||||
@@ -1,7 +0,0 @@
|
||||
# The touchpad's settings again after waking, in the operator's session: X applies the module's
|
||||
# input class when the device appears, and this covers a wake that does not initialise it again.
|
||||
# Runs as root from the power module; the reset itself runs as the session's owner.
|
||||
sleep 2
|
||||
owner=$(ps -o user= -C i3 | head -n 1)
|
||||
[ -n "$owner" ] && runuser -u "$owner" -- /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
|
||||
true
|
||||
@@ -1,30 +0,0 @@
|
||||
# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the
|
||||
# mesh: replaced at every push. Once the controller places contributions to node-display-session
|
||||
# (ADR 0210), these become the module's contribution instead of a file in i3's directory.
|
||||
#
|
||||
# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are
|
||||
# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for
|
||||
# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's.
|
||||
|
||||
# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the
|
||||
# screenshot grabs the pointer to select a region, which fails while the key is still held.
|
||||
bindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh
|
||||
|
||||
# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the
|
||||
# external output.
|
||||
bindsym $mod+p exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display order
|
||||
|
||||
# The keyboard backlight's level, shown when it changes.
|
||||
exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-kbd-notify
|
||||
# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the
|
||||
# mesh: replaced at every push. Once the controller places contributions to node-display-session
|
||||
# (ADR 0210), these become the module's contribution instead of a file in i3's directory.
|
||||
#
|
||||
# The model's panel and touchpad.
|
||||
|
||||
# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where
|
||||
# is the display server's (autorandr, run at every session start).
|
||||
exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display primary
|
||||
|
||||
# The touchpad's settings again, by hand. X applies them itself whenever the device appears.
|
||||
bindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
|
||||
@@ -1,10 +0,0 @@
|
||||
# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness,
|
||||
# and the touchpad key. Each runs this module's own script, as the operator's account.
|
||||
KEY_PROG1 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause
|
||||
KEY_PROG3 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous
|
||||
KEY_PROG4 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next
|
||||
KEY_BRIGHTNESSDOWN 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -
|
||||
KEY_BRIGHTNESSDOWN 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -
|
||||
KEY_BRIGHTNESSUP 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +
|
||||
KEY_BRIGHTNESSUP 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +
|
||||
KEY_F21 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
|
||||
@@ -1,16 +0,0 @@
|
||||
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||
#
|
||||
# The discrete GPU's driver options on the ROG Zephyrus G14 (GA403, RTX 40 series, hybrid graphics).
|
||||
#
|
||||
# NVreg_DynamicPowerManagement=0x00 turns runtime D3 off. With it on, a change of power source sends
|
||||
# the driver an ACPI notification it fails to handle on this model ("RmHandleDNotifierEvent: Failed to
|
||||
# handle ACPI D-Notifier event, status=0x62"), and the GPU stops making progress until the machine is
|
||||
# powered off. Off costs a few idle watts in hybrid mode and keeps the machine up.
|
||||
#
|
||||
# NVreg_PreserveVideoMemoryAllocations=1 saves video memory across suspend, so what used the GPU still
|
||||
# works after waking. It needs nvidia-suspend, -hibernate and -resume to run around a sleep, which this
|
||||
# module's drop-ins on the sleep services ask for.
|
||||
#
|
||||
# A change here applies when the driver next loads: at the next boot.
|
||||
options nvidia NVreg_PreserveVideoMemoryAllocations=1
|
||||
options nvidia NVreg_DynamicPowerManagement=0x00
|
||||
@@ -1,6 +0,0 @@
|
||||
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||
#
|
||||
# The ACPI video driver does not change the backlight itself on the brightness keys: on this model it
|
||||
# moves the wrong one. The keys are triggerhappy's (see /etc/triggerhappy/triggers.d/asus-g14.conf).
|
||||
# Applies when the module next loads: at the next boot.
|
||||
options video brightness_switch_enabled=0
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||
#
|
||||
# nvidia-powerd (Dynamic Boost) was the first error in the chain that hung this model's GPU on a change
|
||||
# of power source, and asusd starts it on mains. It runs only when the kernel command line says
|
||||
# zephyrus.nvidia-powerd — an explicit opt-in, at boot.
|
||||
[Unit]
|
||||
ConditionKernelCommandLine=zephyrus.nvidia-powerd
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||
#
|
||||
# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as
|
||||
# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service
|
||||
# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.
|
||||
[Unit]
|
||||
Wants=nvidia-hibernate.service nvidia-resume.service
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||
#
|
||||
# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as
|
||||
# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service
|
||||
# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.
|
||||
[Unit]
|
||||
Wants=nvidia-suspend-then-hibernate.service nvidia-resume.service
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||
#
|
||||
# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as
|
||||
# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service
|
||||
# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.
|
||||
[Unit]
|
||||
Wants=nvidia-suspend.service nvidia-resume.service
|
||||
@@ -1,5 +0,0 @@
|
||||
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||
#
|
||||
# The backlights are writable by the video group, so the brightness keys and the module's brightness tool
|
||||
# move the panel without root.
|
||||
ACTION=="add", SUBSYSTEM=="backlight", RUN+="/usr/bin/chgrp video /sys/class/backlight/%k/brightness", RUN+="/usr/bin/chmod g+w /sys/class/backlight/%k/brightness"
|
||||
@@ -1,11 +0,0 @@
|
||||
# audit-logger
|
||||
|
||||
The `audit-logger` module of the mesh catalogue: declares directories on the node.
|
||||
|
||||
## Files it does not know
|
||||
|
||||
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
|
||||
|
||||
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `${dir:trail}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `${dir:spool}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
@@ -47,20 +47,17 @@
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "trail",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"unknown": "program"
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "spool",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"unknown": "program"
|
||||
"mode": "0700"
|
||||
}
|
||||
],
|
||||
"capabilities": [
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user