diff --git a/cmd/mesh-builder/main.go b/cmd/mesh-builder/main.go index e8ece0d8..e47b9ed7 100644 --- a/cmd/mesh-builder/main.go +++ b/cmd/mesh-builder/main.go @@ -18,11 +18,13 @@ package main import ( "context" "encoding/json" + "errors" "fmt" "log" "net/url" "os" "os/signal" + "sort" "strings" "syscall" "time" @@ -54,6 +56,8 @@ is dialled except the broker. MESH_BINDING a file the mesh wrote saying where the artifact store is MESH_PACKAGE_BINDING a file the mesh wrote saying where the package registry is MESH_NPM_TOKEN_FILE a file the mesh sealed holding the token for it + MESH_GENESIS_PACKAGE_REGISTRY the document the installer sealed at genesis, read while no + binding is present (novox/hq ADR 0336) MESH_NPM_REGISTRY a package registry URL, when the mesh has not said (a person, the bootstrap) MESH_NPM_TOKEN the token for it, likewise MESH_NPM_SCOPE the scope it answers for (default: @novox) @@ -254,7 +258,10 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri } say("build", what) - npmrc, err := packagesFrom() + npmrc, err := packagesFrom(on) + if err == nil { + say("packages", saysRegistry(npmrc)) + } var built builder.Result if request.Check != nil { // **A pull request's merge check, not a build** (novox/hq to-be 45 §9): nothing is built, @@ -386,79 +393,171 @@ func layerOf(l *builder.Layer) *link.CheckLayer { } // packagesFrom is where a build resolves the mesh's own published packages — the SDK above all -// (novox/hq ADR 0076, issue 053). +// (novox/hq ADR 0076, issue 053) — on this machine. // -// Preferably from the mesh: a package-registry binding names the endpoint the way the artifact -// store's binding does, and a sealed token file the credential the way the broker's does. The -// environment variables remain for a builder run by a person, and for the bootstrap, where there is -// no registry yet — there the result is disabled and a build that needs no mesh-published dependency -// builds anyway. -func packagesFrom() (builder.Npmrc, error) { +// **The binding wins, then the genesis document** (novox/hq ADR 0336 §3, §4). The build agent's +// npm-package-registry requirement is optional: on genesis's node, before the gitea module is assigned, +// nothing provides it, and the installer sealed the forge it raised to this agent as one document +// instead. Once the requirement binds, the binding names the endpoint the way the artifact store's +// does and a sealed token file the credential, and the genesis document is left unread. A binding +// file named and absent is a requirement not bound here, never a fault: the environment names the +// path whether or not the mesh wrote it. +// +// The environment variables remain for a builder run by a person. With none of the three the result +// is disabled and says this machine, so a build that needs the registry is refused naming it (§6), +// and a build that needs none builds anyway. +func packagesFrom(machine string) (builder.Npmrc, error) { scope := strings.TrimSpace(os.Getenv("MESH_NPM_SCOPE")) if scope == "" { scope = "@novox" } + none := builder.Npmrc{Machine: machine} - registry := strings.TrimSpace(os.Getenv("MESH_NPM_REGISTRY")) - var username string if path := strings.TrimSpace(os.Getenv("MESH_PACKAGE_BINDING")); path != "" { raw, err := os.ReadFile(path) - if err != nil { - return builder.Npmrc{}, fmt.Errorf("cannot read what the mesh said about the package registry: %w", err) + switch { + case errors.Is(err, os.ErrNotExist): + // Not bound on this machine: the genesis document, or nothing. + case err != nil: + return none, fmt.Errorf("cannot read what the mesh said about the package registry: %w", err) + default: + return fromBinding(path, raw, scope, machine) } - var told struct { - From string `json:"from"` - At string `json:"at"` - As string `json:"as"` - Serves map[string]any `json:"serves"` + } + if path := strings.TrimSpace(os.Getenv("MESH_GENESIS_PACKAGE_REGISTRY")); path != "" { + raw, err := os.ReadFile(path) + switch { + case errors.Is(err, os.ErrNotExist): + // Not this mesh's genesis node, or the installer has not sealed it yet. + case err != nil: + return none, fmt.Errorf("cannot read the genesis document for the package registry: %w", err) + default: + return fromGenesis(path, raw, scope, machine) } - if err := json.Unmarshal(raw, &told); err != nil { - return builder.Npmrc{}, fmt.Errorf("%s is not a binding: %w", path, err) - } - if told.At == "" { - return builder.Npmrc{}, fmt.Errorf( - "%s says the package registry is on %q and gives no address for it", path, told.From) - } - // Composed from what the provider serves, so nothing here knows gitea's URL shape from - // another registry's: it states its port, the path its registry answers on, and the scheme. - scheme := "https" - if s, ok := told.Serves["scheme"]; ok { - scheme = fmt.Sprintf("%v", s) - } - port, ok := told.Serves["port"] - if !ok { - return builder.Npmrc{}, fmt.Errorf("%s says nothing about which port the package registry answers on", path) - } - npmPath, ok := told.Serves["npm-path"] - if !ok { - return builder.Npmrc{}, fmt.Errorf("%s says nothing about the path the package registry answers on", path) - } - registry = fmt.Sprintf("%s://%s:%v%v", scheme, told.At, port, npmPath) - username = told.As } - // The credential the mesh sealed to this machine. The mesh authenticates the ordinary way — a - // generated password the provider only applies (novox/hq ADR 0048) — so with a username this is - // a password (basic auth); without one it is a bearer token a provider minted. + // A builder run by a person. + registry := strings.TrimSpace(os.Getenv("MESH_NPM_REGISTRY")) secret := strings.TrimSpace(os.Getenv("MESH_NPM_TOKEN")) - if path := strings.TrimSpace(os.Getenv("MESH_NPM_TOKEN_FILE")); path != "" { - raw, err := os.ReadFile(path) + username := strings.TrimSpace(os.Getenv("MESH_NPM_USER")) + if registry == "" && secret == "" { + return none, nil + } + n := builder.Npmrc{Scope: scope, Registry: registry, From: builder.FromEnvironment, Machine: machine} + if username != "" { + n.Username, n.Password = username, secret + } else { + n.Token = secret + } + return n, nil +} + +// fromBinding composes the registry from the npm-package-registry binding the mesh wrote and the +// credential it sealed beside it. The mesh authenticates the ordinary way — a generated password the +// provider only applies (novox/hq ADR 0048) — so with a username the secret is a password (basic +// auth); without one it is a bearer token a provider minted. +func fromBinding(path string, raw []byte, scope, machine string) (builder.Npmrc, error) { + none := builder.Npmrc{Machine: machine} + var told struct { + From string `json:"from"` + At string `json:"at"` + As string `json:"as"` + Serves map[string]any `json:"serves"` + } + if err := json.Unmarshal(raw, &told); err != nil { + return none, fmt.Errorf("%s is not a binding: %w", path, err) + } + if told.At == "" { + return none, fmt.Errorf( + "%s says the package registry is on %q and gives no address for it", path, told.From) + } + // Composed from what the provider serves, so nothing here knows gitea's URL shape from + // another registry's: it states its port, the path its registry answers on, and the scheme. + scheme := "https" + if s, ok := told.Serves["scheme"]; ok { + scheme = fmt.Sprintf("%v", s) + } + port, ok := told.Serves["port"] + if !ok { + return none, fmt.Errorf("%s says nothing about which port the package registry answers on", path) + } + npmPath, ok := told.Serves["npm-path"] + if !ok { + return none, fmt.Errorf("%s says nothing about the path the package registry answers on", path) + } + secret := strings.TrimSpace(os.Getenv("MESH_NPM_TOKEN")) + if file := strings.TrimSpace(os.Getenv("MESH_NPM_TOKEN_FILE")); file != "" { + raw, err := os.ReadFile(file) if err != nil { - return builder.Npmrc{}, fmt.Errorf("cannot read this builder's package-registry credential: %w", err) + return none, fmt.Errorf("cannot read this builder's package-registry credential: %w", err) } secret = strings.TrimSpace(string(raw)) } - if u := strings.TrimSpace(os.Getenv("MESH_NPM_USER")); u != "" { - username = u + n := builder.Npmrc{Scope: scope, Registry: fmt.Sprintf("%s://%s:%v%v", scheme, told.At, port, npmPath), + From: builder.FromBinding, Machine: machine} + if told.As != "" { + n.Username, n.Password = told.As, secret + } else { + n.Token = secret } + return n, nil +} - if registry == "" && secret == "" { - return builder.Npmrc{}, nil +// GenesisRegistry is the genesis document (novox/hq ADR 0336 §3): what the installer sealed to the +// build agent on its node at genesis's packages step, as the `genesis-package-registry` own-secret — +// the forge it raised, the port this node gave the package registry, its npm path, and the account it +// made with that account's password. mesh-host writes it; the field names are the two repositories' +// agreement. +type GenesisRegistry struct { + Scheme string `json:"scheme"` + At string `json:"at"` + Port int `json:"port"` + NpmPath string `json:"npm-path"` + As string `json:"as"` + Password string `json:"password"` +} + +// fromGenesis composes the registry from the genesis document. +func fromGenesis(path string, raw []byte, scope, machine string) (builder.Npmrc, error) { + none := builder.Npmrc{Machine: machine} + var g GenesisRegistry + if err := json.Unmarshal(raw, &g); err != nil { + return none, fmt.Errorf("%s is not the genesis document for the package registry: %w", path, err) } - if username != "" { - return builder.Npmrc{Scope: scope, Registry: registry, Username: username, Password: secret}, nil + var missing []string + for name, value := range map[string]string{"at": g.At, "npm-path": g.NpmPath, "as": g.As, "password": g.Password} { + if strings.TrimSpace(value) == "" { + missing = append(missing, name) + } } - return builder.Npmrc{Scope: scope, Registry: registry, Token: secret}, nil + if g.Port <= 0 { + missing = append(missing, "port") + } + if len(missing) > 0 { + sort.Strings(missing) + return none, fmt.Errorf("%s, the genesis document for the package registry, says nothing of %s", + path, strings.Join(missing, ", ")) + } + scheme := g.Scheme + if scheme == "" { + scheme = "http" + } + return builder.Npmrc{Scope: scope, Registry: fmt.Sprintf("%s://%s:%d%s", scheme, g.At, g.Port, g.NpmPath), + Username: g.As, Password: g.Password, From: builder.FromGenesis, Machine: machine}, nil +} + +// saysRegistry is the line each build's log carries about the package registry (novox/hq ADR 0336 +// §4): which of the binding and the genesis document it resolved from, or that it has neither here. +func saysRegistry(n builder.Npmrc) string { + if n.Enabled() { + return "the package registry is resolved from " + n.From + } + machine := n.Machine + if machine == "" { + machine = "this machine" + } + return fmt.Sprintf("no package registry: %s is unbound on %s and no genesis document is here; "+ + "a build needing it is refused", builder.RegistryRequirement, machine) } // forgeFrom is the git credential this builder may offer a clone, composed from the same binding diff --git a/cmd/mesh-builder/once.go b/cmd/mesh-builder/once.go index ffe42851..11df0bae 100644 --- a/cmd/mesh-builder/once.go +++ b/cmd/mesh-builder/once.go @@ -84,10 +84,15 @@ func buildOnce(ctx context.Context, args []string) error { } fmt.Fprintln(os.Stderr) - npmrc, err := packagesFrom() + machine := os.Getenv("MESH_NODE") + if machine == "" { + machine, _ = os.Hostname() + } + npmrc, err := packagesFrom(machine) if err != nil { return err } + fmt.Fprintf(os.Stderr, " [packages] %s\n", saysRegistry(npmrc)) built, buildErr := builder.Build(ctx, builder.Command, publisher, repository, *path, *ref, where, bases, npmrc, forgeFrom(), func(step, message string) { fmt.Fprintf(os.Stderr, " [%s] %s\n", step, message) }) diff --git a/cmd/mesh-builder/packages_test.go b/cmd/mesh-builder/packages_test.go new file mode 100644 index 00000000..2fb4a719 --- /dev/null +++ b/cmd/mesh-builder/packages_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/builder" +) + +// The build agent's package registry (novox/hq ADR 0336, issue 121): its npm-package-registry +// requirement is optional, so the binding may be absent, and on genesis's node the installer seals +// the forge it raised as one genesis document instead. + +// registryFiles names the three paths the build agent's environment always names, writing only those +// given. +func registryFiles(t *testing.T, binding, token, genesis string) { + t.Helper() + dir := t.TempDir() + for env, content := range map[string]string{ + "MESH_PACKAGE_BINDING": binding, + "MESH_NPM_TOKEN_FILE": token, + "MESH_GENESIS_PACKAGE_REGISTRY": genesis, + } { + path := filepath.Join(dir, env) + t.Setenv(env, path) + if content != "" { + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + } + for _, env := range []string{"MESH_NPM_REGISTRY", "MESH_NPM_TOKEN", "MESH_NPM_USER", "MESH_NPM_SCOPE"} { + t.Setenv(env, "") + } +} + +const ( + aBinding = `{"from":"gitea","at":"anchor.mesh","as":"anchor-agent", + "serves":{"scheme":"http","port":3000,"npm-path":"/api/packages/novox/npm/"}}` + aGenesisDocument = `{"scheme":"http","at":"127.0.0.1","port":3100,"npm-path":"/api/packages/novox/npm/", + "as":"mesh-builder","password":"genesis-secret"}` +) + +func TestUntilTheRequirementBindsTheGenesisDocumentIsRead(t *testing.T) { + registryFiles(t, "", "", aGenesisDocument) + n, err := packagesFrom("anchor") + if err != nil { + t.Fatalf("an absent binding beside a genesis document was refused: %v", err) + } + if n.From != builder.FromGenesis || n.Registry != "http://127.0.0.1:3100/api/packages/novox/npm/" || + n.Username != "mesh-builder" || n.Password != "genesis-secret" || n.Scope != "@novox" { + t.Fatalf("the genesis document was not what the registry came from: %+v", n) + } + file, err := n.File() + if err != nil || !strings.Contains(file, "//127.0.0.1:3100/api/packages/novox/npm/:username=mesh-builder") { + t.Fatalf("the .npmrc is not written from the genesis document: %v\n%s", err, file) + } + if got := saysRegistry(n); !strings.Contains(got, "genesis document") { + t.Errorf("the log does not say the genesis document: %s", got) + } +} + +func TestARealBindingWinsOverTheGenesisDocument(t *testing.T) { + registryFiles(t, aBinding, "bound-secret\n", aGenesisDocument) + n, err := packagesFrom("anchor") + if err != nil { + t.Fatal(err) + } + if n.From != builder.FromBinding || n.Registry != "http://anchor.mesh:3000/api/packages/novox/npm/" || + n.Username != "anchor-agent" || n.Password != "bound-secret" { + t.Fatalf("the binding did not win over the genesis document: %+v", n) + } + if got := saysRegistry(n); !strings.Contains(got, "binding") { + t.Errorf("the log does not say the binding: %s", got) + } +} + +func TestWithNeitherTheAgentHasNoRegistryAndSaysWhere(t *testing.T) { + registryFiles(t, "", "", "") + n, err := packagesFrom("workstation") + if err != nil { + t.Fatalf("an unbound optional requirement was a fault: %v", err) + } + if n.Enabled() || n.Machine != "workstation" { + t.Fatalf("a registry came from nowhere: %+v", n) + } + got := saysRegistry(n) + for _, want := range []string{"npm-package-registry", "unbound", "workstation"} { + if !strings.Contains(got, want) { + t.Errorf("the log does not say %q: %s", want, got) + } + } +} + +func TestAGenesisDocumentMissingAFieldIsRefusedByName(t *testing.T) { + registryFiles(t, "", "", `{"at":"127.0.0.1","port":3000,"npm-path":"/api/packages/novox/npm/","as":"mesh-builder"}`) + if _, err := packagesFrom("anchor"); err == nil || !strings.Contains(err.Error(), "password") { + t.Fatalf("a genesis document with no password was taken: %v", err) + } +} diff --git a/internal/builder/builder.go b/internal/builder/builder.go index b64856f3..586b375f 100644 --- a/internal/builder/builder.go +++ b/internal/builder/builder.go @@ -251,6 +251,20 @@ func build(ctx context.Context, run Runner, publish Publisher, // never resolve a mesh package — making their image non-deterministic (a needless rollout every // build) and leaking the credential into a build stage. Absent entirely with no registry, which // is the bootstrap case (novox/hq ADR 0076). + // **A build that needs the registry and has none is refused before anything is built or + // published** (novox/hq ADR 0336 §6) — before a base is copied into the store, and never built + // without the credential or against the public registry. + if !npmrc.Enabled() { + why, err := needsRegistry(manifest, within) + if err != nil { + return Result{}, err + } + if why != "" { + err := npmrc.unbound(manifest.Module, why) + say("packages", "REFUSED: %v", err) + return Result{}, err + } + } var npmrcPath string if npmrc.Enabled() && manifest.Build != nil && wantsPackages(manifest, within) { content, err := npmrc.File() @@ -263,7 +277,7 @@ func build(ctx context.Context, run Runner, publish Publisher, } // Only for as long as the build: a later check's container mounts this workspace (novox/hq issue 462). defer os.Remove(npmrcPath) - say("packages", "resolving %s from the mesh's package registry", npmrc.Scope) + say("packages", "resolving %s from the mesh's package registry, from %s", npmrc.Scope, fromOr(npmrc.From)) src.notPinned("it resolves packages from the mesh's registry at build time") } @@ -701,6 +715,14 @@ func against(within string, manifest catalogue.Manifest, resolved []string) []st return out } +// fromOr is where a registry was resolved from, as a build's log says it. +func fromOr(from string) string { + if from == "" { + return "what this build was handed" + } + return from +} + // ManifestName is the one file a module repository must have. // // At the root, and named the same in every repository. A convention somebody can look for beats a diff --git a/internal/builder/packages.go b/internal/builder/packages.go index d30cacda..c961d9a9 100644 --- a/internal/builder/packages.go +++ b/internal/builder/packages.go @@ -2,9 +2,16 @@ package builder import ( "encoding/base64" + "encoding/json" + "errors" "fmt" "net/url" + "os" + "path/filepath" + "sort" "strings" + + "github.com/novox/mesh-controller/internal/catalogue" ) // Npmrc is what a build needs to resolve the mesh's own shared library — and any other package the @@ -37,8 +44,36 @@ type Npmrc struct { // username is the consumer's mesh identity. Ignored when Token is set. Username string Password string + + // From says where the build agent resolved this registry from (novox/hq ADR 0336 §4): its + // npm-package-registry binding, the genesis document the installer sealed to it, or the + // environment of a builder run by a person. Empty when it resolved none. + From string + // Machine is the machine the build agent runs on, named when a build that needs the registry is + // refused for having none (ADR 0336 §6). + Machine string } +// Where a build agent resolves the mesh's package registry from (novox/hq ADR 0336 §3, §4). +const ( + // FromBinding is the npm-package-registry binding the mesh wrote, with the credential it sealed. + FromBinding = "the npm-package-registry binding" + // FromGenesis is the genesis document the installer sealed at genesis's packages step, read only + // while no binding is present. + FromGenesis = "the genesis document" + // FromEnvironment is the environment of a builder run by a person. + FromEnvironment = "the environment" +) + +// RegistryRequirement is the build agent's optional requirement the registry is bound through, and +// RegistryProvider the catalogue module that provides it (novox/hq ADR 0336 §1, §6): named when a +// build that needs the registry is refused, so the outcome says what to assign. The controller's +// catalogue test holds the provider to the catalogue's. +const ( + RegistryRequirement = "npm-package-registry" + RegistryProvider = "gitea" +) + // Enabled reports whether there is a registry to resolve against at all. A bootstrap build that // runs before any package registry exists has none, and must still build whatever needs no // mesh-published dependency. @@ -90,6 +125,77 @@ func (n Npmrc) File() (string, error) { return fmt.Sprintf("%s:registry=%s\n%s", scope, reg, auth), nil } +// needsRegistry says why this module's build needs the mesh's package registry, or "" when it needs +// none (novox/hq ADR 0336 §5): a `package` artifact, an image whose Dockerfile names .npmrc, or a +// TypeScript artifact with an `@novox/` dependency. Every Go artifact, every image that does not name +// .npmrc, and every image pulled upstream builds without it. +func needsRegistry(manifest catalogue.Manifest, within string) (string, error) { + if manifest.Build == nil { + return "", nil + } + for _, a := range manifest.Build.Artifacts { + switch { + case a.Kind == catalogue.ArtifactPackage: + return fmt.Sprintf("%s is a package, published to the registry", a.Name), nil + case a.Kind == catalogue.ArtifactImage: + raw, err := os.ReadFile(filepath.Join(within, a.From)) + if err == nil && strings.Contains(string(raw), ".npmrc") { + return fmt.Sprintf("%s's Dockerfile names .npmrc", a.Name), nil + } + case a.Language == "typescript": + scoped, err := scopedDependencies(within) + if err != nil { + return "", err + } + if len(scoped) > 0 { + return fmt.Sprintf("%s is TypeScript and depends on %s", a.Name, strings.Join(scoped, ", ")), nil + } + } + } + return "", nil +} + +// scopedDependencies is every `@novox/` package the module's package.json depends on, the SDK +// included. +func scopedDependencies(tree string) ([]string, error) { + raw, err := os.ReadFile(filepath.Join(tree, "package.json")) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var p struct { + Dependencies map[string]string `json:"dependencies"` + } + if err := json.Unmarshal(raw, &p); err != nil { + return nil, fmt.Errorf("the module's package.json is not JSON: %w", err) + } + var names []string + for name := range p.Dependencies { + if strings.HasPrefix(name, "@novox/") { + names = append(names, name) + } + } + sort.Strings(names) + return names, nil +} + +// unbound is the refusal of a build that needs the registry on a build agent that has none (novox/hq +// ADR 0336 §6): it names the machine, the requirement left unbound there and the module that provides +// it. Nothing is built, nothing is published, and the `@novox` scope is never resolved from the +// public registry instead. +func (n Npmrc) unbound(module, why string) error { + machine := n.Machine + if machine == "" { + machine = "this machine" + } + return fmt.Errorf("%s needs the mesh's package registry (%s), and %s is unbound on %s: the build "+ + "agent there has neither its binding nor the genesis document. It is provided by the %s module; "+ + "once %s is assigned the requirement binds (novox/hq ADR 0336 §6)", + module, why, RegistryRequirement, machine, RegistryProvider, RegistryProvider) +} + // packageRecipe is how a `package` artifact is built and published: on a PUBLIC base image, never // the mesh toolchain, because the toolchain is built from the package this produces (the SDK). The // script builds the module, then publishes it to the mesh's package registry unless that exact diff --git a/internal/builder/registry_unbound_test.go b/internal/builder/registry_unbound_test.go new file mode 100644 index 00000000..d766a72d --- /dev/null +++ b/internal/builder/registry_unbound_test.go @@ -0,0 +1,122 @@ +package builder + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/beside" + "github.com/novox/mesh-controller/internal/catalogue" +) + +// **A build that needs the registry fails loudly while the build agent has none** (novox/hq ADR 0336 +// §5, §6, issue 121). On a holder with neither the npm-package-registry binding nor the genesis +// document, a `package` artifact, an image whose Dockerfile names .npmrc and a TypeScript artifact +// with an `@novox/` dependency each fail before anything is built or published, naming the machine, +// the unbound requirement and the module that provides it. A Go artifact, and an image that does not +// name .npmrc, build there as before. +func TestABuildNeedingTheRegistryIsRefusedOnAnAgentWithoutOne(t *testing.T) { + none := Npmrc{Machine: "workstation"} + cases := map[string]struct { + manifest string + files map[string]string + held map[string]string + }{ + "a package": {aPackage, map[string]string{"package.json": `{"name":"@novox/mesh-sdk","version":"0.1.0"}`}, nil}, + "an image naming .npmrc": {withBoth, map[string]string{ + "Dockerfile": "FROM scratch\nCOPY .npmrc /root/.npmrc\n", "files/x": "y"}, nil}, + "a TypeScript bundle depending on the SDK": {aBundle, map[string]string{ + "index.ts": "console.log(1)", "package.json": `{"dependencies":{"@novox/mesh-sdk":"^0.1.0"}}`}, + map[string]string{"mesh-tools/build": "registry.invalid/mesh-tools/build@sha256:" + strings.Repeat("b", 64)}}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + r, workspace := aRepository(t, c.manifest, c.files) + var said []string + _, err := Build(context.Background(), compiling{r}.run, r, + "https://forge.invalid/m.git", "", "", workspace, c.held, none, GitCredential{}, + func(step, message string) { said = append(said, step+": "+message) }) + if err == nil { + t.Fatal("built with no package registry") + } + for _, want := range []string{"workstation", "npm-package-registry", "unbound", "gitea"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not say %q: %v", want, err) + } + } + for _, line := range r.ran { + if strings.HasPrefix(line, "docker build") || strings.HasPrefix(line, "docker run") || + strings.HasPrefix(line, "docker pull") { + t.Errorf("something was built after the refusal: %s", line) + } + } + if len(r.images) != 0 || len(r.archives) != 0 { + t.Errorf("something was published after the refusal: %v %v", r.images, r.archives) + } + if !strings.Contains(strings.Join(said, "\n"), "REFUSED") { + t.Errorf("the build's log does not say it was refused:\n%s", strings.Join(said, "\n")) + } + }) + } + + // A Go artifact builds on the same agent. + files := aSharedRepository("one", "1") + manifest := `{"module":"ctl","version":"1","build":{"artifacts":[ + {"name":"controller","kind":"bundle","language":"go","system":"arch","from":"cmd/ctl","binary":"ctl"}]}, + "resources":[{"id":"controller","type":"process","name":"ctl","artifact":"controller","run":["./ctl"]}]}` + r := &recorded{contents: map[string]string{ManifestName: manifest}} + for k, v := range files { + r.contents[k] = v + } + held := map[string]string{"mesh-tools-go/build": "registry.invalid/mesh-tools-go/build@sha256:" + strings.Repeat("b", 64)} + if _, err := Build(context.Background(), onTrunk{recorded: r}.run, r, + "https://forge.invalid/ctl.git", "", "", t.TempDir(), held, none, GitCredential{}, nil); err != nil { + t.Fatalf("a Go artifact did not build on an agent without the registry: %v", err) + } + // And an image that does not name .npmrc. + r, workspace := aRepository(t, withBoth, map[string]string{"Dockerfile": "FROM scratch\nCOPY . .", "files/x": "y"}) + if _, err := Build(context.Background(), r.run, r, + "https://forge.invalid/meshboard.git", "", "", workspace, nil, none, GitCredential{}, nil); err != nil { + t.Fatalf("an image not naming .npmrc did not build on an agent without the registry: %v", err) + } +} + +// **The build's log says which registry it resolved from** (novox/hq ADR 0336 §4). +func TestABuildSaysWhichRegistryItResolvedFrom(t *testing.T) { + for _, from := range []string{FromBinding, FromGenesis} { + r, workspace := aRepository(t, aPackage, map[string]string{ + "package.json": `{"name":"@novox/mesh-sdk","version":"0.1.0"}`, + }) + n := Npmrc{Scope: "@novox", Registry: "http://127.0.0.1:3000/api/packages/novox/npm/", + Username: "mesh-builder", Password: "p", From: from, Machine: "anchor"} + var said []string + if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/mesh-sdk.git", "", "", + workspace, nil, n, GitCredential{}, func(step, message string) { said = append(said, step+": "+message) }); err != nil { + t.Fatalf("%s: %v", from, err) + } + if !strings.Contains(strings.Join(said, "\n"), "from "+from) { + t.Errorf("the log does not say the registry came from %s:\n%s", from, strings.Join(said, "\n")) + } + } +} + +// The refusal names the module that provides the registry; it is the catalogue's provider. +func TestTheRegistryABuildIsRefusedForNamesItsProvider(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(beside.Catalogue(t), RegistryProvider, "module.json")) + if err != nil { + t.Fatal(err) + } + forge, err := catalogue.ParseManifest(raw) + if err != nil { + t.Fatal(err) + } + provides := false + for _, p := range forge.Provides { + provides = provides || p.Name == RegistryRequirement + } + if !provides { + t.Fatalf("%s does not provide %s, which a refused build names it for", RegistryProvider, RegistryRequirement) + } +} diff --git a/internal/catalogue/genesis_registry_test.go b/internal/catalogue/genesis_registry_test.go new file mode 100644 index 00000000..1e5a9575 --- /dev/null +++ b/internal/catalogue/genesis_registry_test.go @@ -0,0 +1,85 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// Genesis assigns the build agent before the package registry exists (novox/hq ADR 0336, issue 121), +// judged over the real catalogue. Genesis's step 13 was refused with "nothing provides +// \"npm-package-registry\", wanted by build-agent": the forge genesis raises at its packages step is a +// container, not the gitea module, and provides nothing. + +// agentBuilt is the build agent as the mesh holds it once built: its image pinned. +func agentBuilt(t *testing.T) Manifest { + t.Helper() + agent := catalogueManifest(t, "build-agent") + built, err := agent.Resolve([]Built{{Name: "server", Kind: ArtifactImage, + Reference: "mesh-builder@sha256:" + strings.Repeat("a", 64)}}) + if err != nil { + t.Fatalf("the build agent does not resolve against its own build: %v", err) + } + return built +} + +func TestGenesisAssignsTheBuildAgentWithNothingProvidingTheRegistry(t *testing.T) { + agent := agentBuilt(t) + if !agent.IsOptional("npm-package-registry") { + t.Fatalf("the build agent's npm-package-registry is hard (ADR 0336 §1): %v", agent.Requires) + } + if agent.IsOptional("artifact-store") { + t.Fatal("the build agent's artifact-store is optional; it stays hard") + } + store := map[string][]Provider{"artifact-store": { + {Node: "anchor", At: "anchor.internal", Module: "distribution", Serves: map[string]any{"port": float64(5000)}}}} + books := shelf(agent, catalogueManifest(t, "gitea"), catalogueManifest(t, "distribution")) + + // Genesis's node at step 13: the store answers, nothing provides the registry. + got, err := Resolve(books, []string{"build-agent"}, reachable(), World{Offered: store}) + if err != nil { + t.Fatalf("assigning the build agent with no registry was refused: %v", err) + } + if have := strings.Join(names(got), " "); have != "build-agent" { + t.Fatalf("assigning the build agent put %s on the machine; nothing is installed for the registry", have) + } + if len(got.Absent) != 1 || got.Absent[0] != (Absent{Consumer: "build-agent", Requirement: "npm-package-registry"}) { + t.Fatalf("the registry is not listed unbound for the build agent: %+v", got.Absent) + } + out, err := got.Declaration(Rendering{ArtifactStore: "anchor.internal:5000", + Needed: map[string]map[string]string{"build-agent": {"broker": "sealed-broker"}}}) + if err != nil { + t.Fatalf("the build agent does not compose with the registry unbound: %v", err) + } + for _, id := range []string{"build-agent." + BoundID("npm-package-registry"), "build-agent." + SecretID("npm-package-registry"), + "build-agent." + NeedID("genesis-package-registry")} { + if fileNamed(out, id) != nil { + t.Errorf("%s was delivered with the registry unbound and no genesis document given", id) + } + } + + // The genesis document, once the installer seals it at the packages step (ADR 0336 §3). + out, err = got.Declaration(Rendering{ArtifactStore: "anchor.internal:5000", + Needed: map[string]map[string]string{"build-agent": {"broker": "sealed-broker", "genesis-package-registry": "sealed-genesis"}}}) + if err != nil { + t.Fatal(err) + } + doc := fileNamed(out, "build-agent."+NeedID("genesis-package-registry")) + if doc == nil || doc["sealed"] != "sealed-genesis" || !strings.HasSuffix(doc["path"].(string), "/genesis-package-registry.json") { + t.Fatalf("the genesis document is not delivered where the agent reads it: %v", doc) + } + + // The gitea module assigned: the requirement binds, with no act on the build agent (ADR 0336 §2). + registry := map[string][]Provider{"artifact-store": store["artifact-store"], "npm-package-registry": { + {Node: "anchor", At: "anchor.internal", Module: "gitea", + Serves: map[string]any{"port": float64(3000), "scheme": "http", "npm-path": "/api/packages/novox/npm/"}}}} + bound, err := Resolve(books, []string{"build-agent"}, reachable(), World{Offered: registry}) + if err != nil { + t.Fatal(err) + } + if n := boundFrom(t, bound, "build-agent", "npm-package-registry"); n.Module != "gitea" { + t.Fatalf("bound to %s", n.Module) + } + if len(bound.Absent) != 0 { + t.Fatalf("the registry is still listed unbound once gitea is assigned: %+v", bound.Absent) + } +} diff --git a/testdata/beside/CAPTURED b/testdata/beside/CAPTURED index 0750cad9..1cbaf6a8 100644 --- a/testdata/beside/CAPTURED +++ b/testdata/beside/CAPTURED @@ -7,7 +7,7 @@ The copy carries the catalogue's private details: domains, first names in module private addresses. That is acceptable while mesh-controller is private; if it ever goes public, this copy is a second place to clean besides the catalogue itself. -mesh-catalog b9de001833b1b61b297182b8e3bcdae1cbdeace9 modules/*/module.json, modules/nats/Dockerfile +mesh-catalog e0bc919d4945bb5f13f971f74643a0b714cf45da modules/*/module.json, modules/nats/Dockerfile mesh-host 7a41e120635b3f7b6cb94cca8b3fc4093e4e6110 examples/foundation-first-node.lock mesh-sdk 33917f91ac3cc213b879b32f3c70e2aa70395f98 conformance/events/module-event.json diff --git a/testdata/beside/mesh-catalog/modules/artifact-store-tools/module.json.captured b/testdata/beside/mesh-catalog/modules/artifact-store-tools/module.json.captured index b9c008f9..d17ac5d2 100644 --- a/testdata/beside/mesh-catalog/modules/artifact-store-tools/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/artifact-store-tools/module.json.captured @@ -3,7 +3,9 @@ "version": "1", "invokes": [ "seat:mesh-controller.artifacts", - "seat:mesh-controller.collect" + "seat:mesh-controller.collect", + "mesh-catalog.catalog_modules", + "mesh-catalog.catalog_module" ], "tools": [ "artifact_store_repositories", @@ -11,6 +13,29 @@ "artifact_store_references", "artifact_store_collect" ], + "settings": { + "older-store-names": { + "kind": "preference", + "default": "none", + "why": "the names the artifact store had before, as a definition may still pin an image under them: host names or host:port, separated by spaces; 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)" + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": "." + }, + { + "id": "store-names", + "type": "file", + "path": "${dir:state}/store-names", + "mode": "0600", + "trusted": false, + "content": "# The names the artifact store had before, from the setting older-store-names, written by the mesh.\n# artifact_store_references reads every definition under these too (novox/hq issue 323).\n# Replaced on every push; change the setting, never this file.\nolder=${setting:older-store-names}\n" + } + ], "build": { "artifacts": [ { @@ -24,7 +49,8 @@ "artifact-store-tools" ], "env": { - "MESH_ARTIFACT_STORE_CONTAINER": "mesh-registry" + "MESH_ARTIFACT_STORE_CONTAINER": "mesh-registry", + "MESH_ARTIFACT_STORE_NAMES_FILE": "${dir:state}/store-names" } } ] diff --git a/testdata/beside/mesh-catalog/modules/betterbird/module.json.captured b/testdata/beside/mesh-catalog/modules/betterbird/module.json.captured index b9f05de7..45383ab4 100644 --- a/testdata/beside/mesh-catalog/modules/betterbird/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/betterbird/module.json.captured @@ -1,23 +1,36 @@ { "module": "betterbird", "version": "1", + "upgrade": { + "policy": "record", + "why": "a new build may remove the community package betterbird-bin and replaces the installation in /opt/betterbird, which a running Betterbird has open: the switch on each node and every upgrade wait for the operator, who quits Betterbird first and then pushes (novox/hq ADR 0316 §4, §5)" + }, "requires": [ "x11-display" ], - "settings": { - "prefs": { - "kind": "preference", - "default": "// none", - "why": "Betterbird runs with its own defaults until a machine's assignment names a preference: one line of user_pref(\"name\", value); statements, which the module's user.js holds and Betterbird reads at its start (novox/hq issue 345)" - } - }, + "invokes": [ + "seat:mesh-controller.settings", + "seat:mesh-controller.nodes", + "seat:mesh-controller.node", + "betterbird.betterbird_derive" + ], "tools": [ "betterbird_status", "betterbird_prefs", + "betterbird_config", + "betterbird_config_apply", "betterbird_user_js", "betterbird_log", "betterbird_restart", "betterbird_check", + "betterbird_accounts", + "betterbird_identities", + "betterbird_calendars", + "betterbird_address_books", + "betterbird_filters", + "betterbird_tags", + "betterbird_derive", + "betterbird_drift", "betterbird_reminder_test" ], "resources": [ @@ -29,12 +42,109 @@ "mode": "0755" }, { - "id": "user-js", + "id": "prefs", "type": "file", - "path": "${machine:account-home}/.config/betterbird/user.js", + "path": "${machine:account-home}/.config/betterbird/prefs.json", "owner": "${machine:account}", "mode": "0644", - "content": "// Betterbird's user.js (module betterbird, novox/hq issue 345). Owned by the mesh: the node-engine\n// writes it here at every push, and the module's tools copy it whole into Betterbird's profile\n// (betterbird_user_js with apply, betterbird_restart), where Betterbird reads it at its start and lets it\n// win over the preferences Betterbird saves itself. Change the module's setting prefs, never this file or the profile's copy.\n//\n// Only comments and user_pref(\"name\", value); statements; the tools refuse anything else, and any\n// preference whose name can hold a credential.\n${setting:prefs}\n" + "merge": "json", + "takes-settings": [ + "prefs" + ], + "content": "{\n \"prefs\": {}\n}\n", + "trusted": true + }, + { + "id": "accounts", + "type": "file", + "path": "${machine:account-home}/.config/betterbird/accounts.json", + "owner": "${machine:account}", + "mode": "0600", + "merge": "json", + "takes-settings": [ + "accounts" + ], + "content": "{\n \"accounts\": {}\n}\n", + "trusted": true + }, + { + "id": "configuration", + "type": "file", + "path": "${machine:account-home}/.config/betterbird/configuration.json", + "owner": "${machine:account}", + "mode": "0600", + "merge": "json", + "takes-settings": [ + "identities", + "calendars", + "address-books", + "filters" + ], + "content": "{\n \"identities\": {},\n \"calendars\": {},\n \"address-books\": {},\n \"filters\": {}\n}\n", + "trusted": true + }, + { + "id": "tags", + "type": "file", + "path": "${machine:account-home}/.config/betterbird/tags.json", + "owner": "${machine:account}", + "mode": "0644", + "merge": "json", + "takes-settings": [ + "tags" + ], + "content": "{\n \"tags\": {}\n}\n", + "trusted": false + }, + { + "id": "community-package", + "type": "package", + "package": "betterbird-bin", + "absent": true + }, + { + "id": "installation", + "type": "archive", + "path": "/opt/betterbird", + "artifact": "archive" + }, + { + "id": "owl", + "type": "archive", + "path": "/opt/betterbird/owl", + "artifact": "owl" + }, + { + "id": "policies", + "type": "archive", + "path": "/opt/betterbird/distribution", + "artifact": "distribution" + }, + { + "id": "spelling", + "type": "archive", + "path": "/opt/betterbird/defaults/pref", + "artifact": "defaults" + }, + { + "id": "preparation", + "type": "archive", + "path": "/usr/local/lib/mesh-betterbird", + "artifact": "tools" + }, + { + "id": "start", + "type": "file", + "path": "/usr/local/bin/betterbird", + "mode": "0755", + "content": "#!/bin/sh\n# Betterbird's start, written by the mesh (module betterbird, novox/hq ADR 0316). Replaced at every push.\n#\n# Before Betterbird starts, the module's tools place its user.js in the profile (the settings prefs, accounts, identities, calendars,\n# address-books and tags) and write what Betterbird rewrites itself: the account lists and each declared account's filters. They do\n# it only while no Betterbird of this account runs, because Betterbird rewrites its saved preferences when it quits; a\n# second start while one runs only hands over to it. What the preparation could not do it says here, and\n# betterbird_check says it too. Betterbird starts either way: its configuration never holds the mail back.\n/usr/local/lib/mesh-betterbird/betterbird-tools prepare || echo \"betterbird: the mesh's preparation did not complete; betterbird_check says why\" >&2\nexec /opt/betterbird/betterbird \"$@\"\n" + }, + { + "id": "launcher", + "type": "file", + "path": "/usr/local/share/applications/eu.betterbird.Betterbird.desktop", + "mode": "0644", + "content": "[Desktop Entry]\nVersion=1.0\nType=Application\nName=Betterbird\nGenericName=Mail Client\nComment=Send and receive mail with Betterbird\nExec=/usr/local/bin/betterbird %u\nIcon=/opt/betterbird/chrome/icons/default/default128.png\nTerminal=false\nCategories=Network;Email;\nMimeType=message/rfc822;x-scheme-handler/mailto;application/x-xpinstall;application/x-extension-ics;text/calendar;text/vcard;text/x-vcard;x-scheme-handler/webcal;x-scheme-handler/webcals;x-scheme-handler/mid;\nStartupNotify=true\nStartupWMClass=eu.betterbird.Betterbird\nKeywords=Email;E-mail;Calendar;Thunderbird;\nActions=ComposeMessage;OpenAddressBook;\n# Written by the mesh (module betterbird, novox/hq ADR 0316 §3), in place of the community package's entry:\n# it starts Betterbird through the module's start, and names the icon in the installation, with no link.\n\n[Desktop Action ComposeMessage]\nName=Write new message\nExec=/usr/local/bin/betterbird -compose\n\n[Desktop Action OpenAddressBook]\nName=Open address book\nExec=/usr/local/bin/betterbird -addressbook\n" } ], "build": { @@ -49,6 +159,38 @@ "loads": [ "betterbird-tools" ] + }, + { + "name": "archive", + "kind": "archive", + "url": "https://www.betterbird.eu/downloads/LinuxArchive/betterbird-153.4.0esr-bb10.en-US.linux-x86_64.tar.xz", + "sha256": "38dd24890a66b4cf9a45bc24e15192e00c26b929e1bb8df7974c16a4539129e2", + "version": "153.4.0esr-bb10", + "within": "betterbird", + "names-on-purpose": { + "www.betterbird.eu": "Betterbird's project publishes its Linux release there; the build seat fetches it once, by its pinned sha256 (novox/hq ADR 0316 §2)" + } + }, + { + "name": "owl", + "kind": "archive", + "url": "https://www.beonex.com/owl/owl.xpi", + "sha256": "a54cc3d52113b34f97942ee19190947dee23561e2217f18db3d80ac023991221", + "version": "1.5.6.1", + "file": "owl.xpi", + "names-on-purpose": { + "www.beonex.com": "Owl's vendor publishes its xpi there; the build seat fetches it once, by its pinned sha256 (novox/hq ADR 0316 §6)" + } + }, + { + "name": "distribution", + "kind": "archive", + "from": "install/distribution" + }, + { + "name": "defaults", + "kind": "archive", + "from": "install/defaults-pref" } ] } diff --git a/testdata/beside/mesh-catalog/modules/build-agent/module.json.captured b/testdata/beside/mesh-catalog/modules/build-agent/module.json.captured index 87a37dbb..c4b1d615 100644 --- a/testdata/beside/mesh-catalog/modules/build-agent/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/build-agent/module.json.captured @@ -14,7 +14,10 @@ ], "requires": [ "artifact-store", - "npm-package-registry" + { + "name": "npm-package-registry", + "optional": true + } ], "binds": { "npm-package-registry": "${dir:mesh-state}/package-registry.json" @@ -23,7 +26,11 @@ "npm-package-registry": "${dir:mesh-state}/package-registry.secret" }, "own-secrets": { - "broker": "${dir:mesh-state}/broker" + "broker": "${dir:mesh-state}/broker", + "genesis-package-registry": { + "path": "${dir:mesh-state}/genesis-package-registry.json", + "issued-by": "outside" + } }, "data": { "own": [ @@ -52,7 +59,7 @@ "type": "file", "path": "${dir:mesh-state}/build-agent.env", "mode": "0600", - "content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_WORKSPACE=${dir:workspace}\n" + "content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_GENESIS_PACKAGE_REGISTRY=/run/mesh/genesis-package-registry.json\nMESH_WORKSPACE=${dir:workspace}\n" }, { "id": "server", @@ -70,7 +77,15 @@ "restart-on": [ "agent-env" ], - "network": "host" + "network": "host", + "health": { + "kind": "exec", + "command": "/usr/local/bin/mesh-builder ready", + "interval": "10s", + "timeout": "5s", + "looks": 3, + "grace": "60s" + } } ], "build": { diff --git a/testdata/beside/mesh-catalog/modules/claude-code/module.json.captured b/testdata/beside/mesh-catalog/modules/claude-code/module.json.captured index 5a4c1890..264844b9 100644 --- a/testdata/beside/mesh-catalog/modules/claude-code/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/claude-code/module.json.captured @@ -18,7 +18,8 @@ "servers", "holdings", "config", - "proposals" + "proposals", + "questions" ], "reads": [ "claude-licence-manager.bindings" @@ -54,6 +55,8 @@ "claude_code_instructions_unregister", "claude_code_instructions_propose", "claude_code_proposals", + "claude_code_ask", + "claude_code_answer", "claude_code_settings_get", "claude_code_settings_set", "claude_code_settings_clear", @@ -67,7 +70,8 @@ "claude_code_home_remove", "claude_code_home_removed", "claude_code_home_restore", - "claude_code_judge" + "claude_code_judge", + "claude_code_judged" ], "data": { "own": [ diff --git a/testdata/beside/mesh-catalog/modules/docker/module.json.captured b/testdata/beside/mesh-catalog/modules/docker/module.json.captured index 73c8f74c..3d267b9d 100644 --- a/testdata/beside/mesh-catalog/modules/docker/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/docker/module.json.captured @@ -44,6 +44,18 @@ "docker exec cat /etc/resolv.conf" ] }, + "settings": { + "log-max-size": { + "kind": "preference", + "default": "100m", + "why": "each container's log is cut at this size and the runtime keeps log-max-file of them, so 100m and 10 hold about 1 GB per container: what the one machine with rotation set by hand has (novox/hq issue 452). A whole number from 1 with k, m or g after it, refused before daemon.json is written. The runtime reads it only at its next start, which the mesh never forces, and a container created before that keeps the options it was created with until it is created again" + }, + "log-max-file": { + "kind": "preference", + "default": 10, + "why": "how many of a container's log files of log-max-size the runtime keeps, the oldest dropped first; a count from 1 to 99, refused before daemon.json is written, taken at the runtime's next start and by containers created after it, like log-max-size (novox/hq issue 452)" + } + }, "resources": [ { "id": "package", @@ -62,13 +74,45 @@ "state": "running", "boot": "enabled" }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": "mesh" + }, + { + "id": "log-rotation", + "type": "file", + "path": "${dir:state}/log-rotation.conf", + "mode": "0600", + "trusted": true, + "content": "# The log rotation daemon.json takes, written by the mesh from this machine's settings log-max-size and\n# log-max-file, and judged by the step log-rotation-check before daemon.json is written (novox/hq issue 452).\n# Replaced on every push; change the settings, never this file.\nsize=${setting:log-max-size}\nfiles=${setting:log-max-file}\n" + }, + { + "id": "log-rotation-check", + "type": "process", + "name": "docker-log-rotation-check", + "artifact": "tools", + "run": [ + "./docker-tools", + "check-log-rotation" + ], + "run-once": true, + "env": { + "DOCKER_LOG_ROTATION": "${dir:state}/log-rotation.conf" + }, + "restart-on": [ + "log-rotation" + ] + }, { "id": "daemon", "type": "file", "path": "/etc/docker/daemon.json", "mode": "0644", "into": "json", - "content": "{\"live-restore\": true, \"insecure-registries\": [\"${seat:mesh-artifact-store:reach}\"]}\n" + "trusted": true, + "content": "{\"live-restore\": true, \"insecure-registries\": [\"${seat:mesh-artifact-store:reach}\"], \"log-driver\": \"json-file\", \"log-opts\": {\"max-size\": \"${setting:log-max-size}\", \"max-file\": \"${setting:log-max-file}\"}}\n" }, { "id": "runtime", diff --git a/testdata/beside/mesh-catalog/modules/gitea/module.json.captured b/testdata/beside/mesh-catalog/modules/gitea/module.json.captured index 1088a76b..6280acff 100644 --- a/testdata/beside/mesh-catalog/modules/gitea/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/gitea/module.json.captured @@ -45,7 +45,8 @@ "pull.closed" ], "consumes": [ - "mesh-controller.checked" + "mesh-controller.checked", + "node-build-agent.started" ], "listens": [ { @@ -256,6 +257,22 @@ "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret", "MESH_NPM_OWNER": "novox" } + }, + { + "name": "forge", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/forge", + "binary": "forge", + "loads": [ + "forge" + ], + "env": { + "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", + "MESH_GITEA_ADMIN_USER": "mesh-admin", + "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret" + } } ] }, diff --git a/testdata/beside/mesh-catalog/modules/grafana/module.json.captured b/testdata/beside/mesh-catalog/modules/grafana/module.json.captured index caa9047d..9ac69bcc 100644 --- a/testdata/beside/mesh-catalog/modules/grafana/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/grafana/module.json.captured @@ -71,20 +71,36 @@ "mode": "0644", "content": "GF_SERVER_ROOT_URL=https://${bound:route:name}\nGF_AUTH_GENERIC_OAUTH_ENABLED=true\nGF_AUTH_GENERIC_OAUTH_NAME=Keycloak\nGF_AUTH_GENERIC_OAUTH_CLIENT_ID=${bound:oidc-client:as}\nGF_AUTH_GENERIC_OAUTH_CLIENT_SECRET__FILE=/run/secrets/oidc-client\nGF_AUTH_GENERIC_OAUTH_SCOPES=openid email profile roles\nGF_AUTH_GENERIC_OAUTH_AUTH_URL=${bound:oidc-client:issuer}${bound:oidc-client:authorization-path}\nGF_AUTH_GENERIC_OAUTH_TOKEN_URL=${bound:oidc-client:issuer}${bound:oidc-client:token-path}\nGF_AUTH_GENERIC_OAUTH_API_URL=${bound:oidc-client:issuer}${bound:oidc-client:userinfo-path}\nGF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH=contains(roles[*], 'admin') && 'Admin' || contains(realm_access.roles[*], 'admin') && 'Admin' || 'Viewer'\nGF_AUTH_GENERIC_OAUTH_USE_PKCE=true\nGF_AUTH_GENERIC_OAUTH_ALLOW_SIGN_UP=true\nGF_AUTH_GENERIC_OAUTH_ALLOW_ASSIGN_GRAFANA_ADMIN=true\n" }, + { + "id": "influxdb-datasources", + "type": "directory", + "path": "${dir:state}/datasources", + "mode": "0755", + "owner": "472:472" + }, + { + "id": "influxdb-credential", + "type": "directory", + "path": "${dir:state}/influxdb", + "mode": "0700", + "owner": "472:472" + }, { "id": "influxdb-secret", "type": "file", - "path": "${dir:state}/influxdb-api.secret", + "path": "${dir:influxdb-credential}/influxdb-api", "mode": "0400", "owner": "472:472", - "content": "${secret:influxdb-api}" + "content": "${secret:influxdb-api}", + "only-when-bound": "influxdb-api" }, { "id": "influxdb-datasource", "type": "file", - "path": "${dir:state}/datasource-influxdb.yaml", + "path": "${dir:influxdb-datasources}/mesh-influxdb.yaml", "mode": "0644", - "content": "apiVersion: 1\n# Written by the mesh from grafana's influxdb-api binding; grafana reads it at start. Its own name and\n# uid, so a data source somebody made in the UI is never overwritten, and read-only in the UI because\n# the mesh resets it. The password is read from the file the mesh delivers, never written here.\ndatasources:\n - name: InfluxDB (mesh)\n uid: mesh-influxdb-api\n type: influxdb\n access: proxy\n url: ${bound:influxdb-api:scheme}://${bound:influxdb-api:at}:${bound:influxdb-api:port}\n user: ${bound:influxdb-api:as}\n isDefault: false\n editable: false\n jsonData:\n dbName: ${bound:influxdb-api:bucket}\n httpMode: POST\n secureJsonData:\n password: $__file{/run/secrets/influxdb-api}\n" + "content": "apiVersion: 1\n# Written by the mesh from grafana's influxdb-api binding, only while it is bound (novox/hq ADR 0330):\n# without influxdb, grafana runs with no mesh data source. grafana reads it at start. Its own name and\n# uid, so a data source somebody made in the UI is never overwritten, and read-only in the UI because\n# the mesh resets it. The password is read from the file the mesh delivers, never written here.\ndatasources:\n - name: InfluxDB (mesh)\n uid: mesh-influxdb-api\n type: influxdb\n access: proxy\n url: ${bound:influxdb-api:scheme}://${bound:influxdb-api:at}:${bound:influxdb-api:port}\n user: ${bound:influxdb-api:as}\n isDefault: false\n editable: false\n jsonData:\n dbName: ${bound:influxdb-api:bucket}\n httpMode: POST\n secureJsonData:\n password: $__file{/run/secrets/influxdb/influxdb-api}\n", + "only-when-bound": "influxdb-api" }, { "id": "server", @@ -103,8 +119,8 @@ "${dir:data}:/var/lib/grafana", "${dir:state}/admin.secret:/run/secrets/admin:ro", "${dir:state}/oidc-client.secret:/run/secrets/oidc-client:ro", - "${dir:state}/influxdb-api.secret:/run/secrets/influxdb-api:ro", - "${dir:state}/datasource-influxdb.yaml:/etc/grafana/provisioning/datasources/mesh-influxdb.yaml:ro" + "${dir:influxdb-credential}:/run/secrets/influxdb:ro", + "${dir:influxdb-datasources}:/etc/grafana/provisioning/datasources:ro" ], "env": { "GF_SECURITY_ADMIN_PASSWORD__FILE": "/run/secrets/admin" @@ -131,7 +147,10 @@ "requires": [ "route", "oidc-client", - "influxdb-api" + { + "name": "influxdb-api", + "optional": true + } ], "contributes": { "route": { diff --git a/testdata/beside/mesh-catalog/modules/home-assistant/module.json.captured b/testdata/beside/mesh-catalog/modules/home-assistant/module.json.captured index 6db00c87..d35ae18e 100644 --- a/testdata/beside/mesh-catalog/modules/home-assistant/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/home-assistant/module.json.captured @@ -123,11 +123,23 @@ } ], "requires": [ - "lidarr-api", - "mqtt-topic", - "radarr-api", + { + "name": "lidarr-api", + "optional": true + }, + { + "name": "mqtt-topic", + "optional": true + }, + { + "name": "radarr-api", + "optional": true + }, "route", - "sonarr-api" + { + "name": "sonarr-api", + "optional": true + } ], "contributes": { "mqtt-topic": { diff --git a/testdata/beside/mesh-catalog/modules/keycloak/module.json.captured b/testdata/beside/mesh-catalog/modules/keycloak/module.json.captured index a903c64f..8bbf9d82 100644 --- a/testdata/beside/mesh-catalog/modules/keycloak/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/keycloak/module.json.captured @@ -162,7 +162,13 @@ "path": "${dir:mesh-state}/config.json", "mode": "0600", "content": "{}\n", - "merge": "json" + "merge": "json", + "takes-settings": [ + "url", + "user", + "realm", + "issuer" + ] } ], "build": { diff --git a/testdata/beside/mesh-catalog/modules/mesh-issues/module.json.captured b/testdata/beside/mesh-catalog/modules/mesh-issues/module.json.captured index 37950085..a6312a6a 100644 --- a/testdata/beside/mesh-catalog/modules/mesh-issues/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/mesh-issues/module.json.captured @@ -16,7 +16,9 @@ "import", "groom", "set", - "tracking" + "tracking", + "reserve", + "fix-times" ] } ], @@ -34,7 +36,9 @@ "import", "groom", "set", - "tracking" + "tracking", + "reserve", + "fix-times" ] } ], @@ -47,7 +51,8 @@ "set", "triaged", "ready", - "ready-to-verify" + "ready-to-verify", + "reserved" ], "consumes": [ "mesh-issues.opened", @@ -57,7 +62,15 @@ "invokes": [ "claude-code.claude_code_judge", "seat:mesh-delivery.checks", - "seat:operator-channel.notify" + "seat:operator-channel.notify", + "gitea.gitea_pull_reviews", + "records.records_status", + "records.records_list", + "records.records_read", + "mesh-knowledge.knowledge_highest" + ], + "logs": [ + "changes" ], "state": [ { @@ -65,7 +78,8 @@ "history": 8 }, "counters", - "grooming" + "grooming", + "reservations" ], "resources": [ { @@ -81,7 +95,7 @@ "path": "${dir:state}/settings.json", "mode": "0600", "merge": "json", - "content": "{\n \"first-number\": 365,\n \"owners\": [\n \"hq\",\n \"mesh-catalog\",\n \"mesh-catalog modules/adwaita\",\n \"mesh-catalog modules/artifact-store-tools\",\n \"mesh-catalog modules/asus-zephyrus-g14\",\n \"mesh-catalog modules/audit-logger\",\n \"mesh-catalog modules/avahi\",\n \"mesh-catalog modules/baserow\",\n \"mesh-catalog modules/betterbird\",\n \"mesh-catalog modules/blueman\",\n \"mesh-catalog modules/bluetooth\",\n \"mesh-catalog modules/build-agent\",\n \"mesh-catalog modules/ca-trust\",\n \"mesh-catalog modules/claude-code\",\n \"mesh-catalog modules/claude-licence-manager\",\n \"mesh-catalog modules/clipmenu\",\n \"mesh-catalog modules/confluence\",\n \"mesh-catalog modules/cups\",\n \"mesh-catalog modules/dbus\",\n \"mesh-catalog modules/de-spiegel\",\n \"mesh-catalog modules/desk-channel\",\n \"mesh-catalog modules/disk-load\",\n \"mesh-catalog modules/distribution\",\n \"mesh-catalog modules/dmenu\",\n \"mesh-catalog modules/dnsmasq\",\n \"mesh-catalog modules/docker\",\n \"mesh-catalog modules/docker-compose\",\n \"mesh-catalog modules/dunst\",\n \"mesh-catalog modules/fail2ban\",\n \"mesh-catalog modules/feh\",\n \"mesh-catalog modules/flatpak\",\n \"mesh-catalog modules/fonts\",\n \"mesh-catalog modules/forticlient\",\n \"mesh-catalog modules/gitea\",\n \"mesh-catalog modules/gitlab\",\n \"mesh-catalog modules/gnome-keyring\",\n \"mesh-catalog modules/grafana\",\n \"mesh-catalog modules/hello-web\",\n \"mesh-catalog modules/home-assistant\",\n \"mesh-catalog modules/hostname\",\n \"mesh-catalog modules/i3\",\n \"mesh-catalog modules/i3status-rust\",\n \"mesh-catalog modules/icecast\",\n \"mesh-catalog modules/influxdb\",\n \"mesh-catalog modules/invoicing\",\n \"mesh-catalog modules/jira\",\n \"mesh-catalog modules/keycloak\",\n \"mesh-catalog modules/lab\",\n \"mesh-catalog modules/lemurs\",\n \"mesh-catalog modules/letta\",\n \"mesh-catalog modules/local-model-consumer\",\n \"mesh-catalog modules/localization\",\n \"mesh-catalog modules/logrotate\",\n \"mesh-catalog modules/mailu\",\n \"mesh-catalog modules/marrytts\",\n \"mesh-catalog modules/matrix\",\n \"mesh-catalog modules/memory-pressure\",\n \"mesh-catalog modules/mesh-catalog\",\n \"mesh-catalog modules/mesh-cli\",\n \"mesh-catalog modules/mesh-delivery\",\n \"mesh-catalog modules/mesh-issues\",\n \"mesh-catalog modules/mesh-vault\",\n \"mesh-catalog modules/mesh-watcher\",\n \"mesh-catalog modules/messenger\",\n \"mesh-catalog modules/minio\",\n \"mesh-catalog modules/model-usage\",\n \"mesh-catalog modules/mongodb\",\n \"mesh-catalog modules/mosquitto\",\n \"mesh-catalog modules/mounts\",\n \"mesh-catalog modules/mssql\",\n \"mesh-catalog modules/n8n\",\n \"mesh-catalog modules/nats\",\n \"mesh-catalog modules/netcheck\",\n \"mesh-catalog modules/networkmanager\",\n \"mesh-catalog modules/nextcloud\",\n \"mesh-catalog modules/nextcloud-client\",\n \"mesh-catalog modules/nfs-server\",\n \"mesh-catalog modules/nftables\",\n \"mesh-catalog modules/nm-applet\",\n \"mesh-catalog modules/node-env\",\n \"mesh-catalog modules/nodered\",\n \"mesh-catalog modules/ollama\",\n \"mesh-catalog modules/only-office\",\n \"mesh-catalog modules/openai-consumer\",\n \"mesh-catalog modules/openrazer\",\n \"mesh-catalog modules/pacman\",\n \"mesh-catalog modules/photos-eef\",\n \"mesh-catalog modules/photos-filip\",\n \"mesh-catalog modules/picom\",\n \"mesh-catalog modules/polychromatic\",\n \"mesh-catalog modules/postgres\",\n \"mesh-catalog modules/power\",\n \"mesh-catalog modules/powerlevel10k\",\n \"mesh-catalog modules/records\",\n \"mesh-catalog modules/redis\",\n \"mesh-catalog modules/restic\",\n \"mesh-catalog modules/rofi\",\n \"mesh-catalog modules/route-adapter\",\n \"mesh-catalog modules/route-proxy\",\n \"mesh-catalog modules/screen-lock\",\n \"mesh-catalog modules/searxng\",\n \"mesh-catalog modules/sensors\",\n \"mesh-catalog modules/showcase\",\n \"mesh-catalog modules/slack\",\n \"mesh-catalog modules/snapd\",\n \"mesh-catalog modules/ssh-client\",\n \"mesh-catalog modules/sshd\",\n \"mesh-catalog modules/step-ca\",\n \"mesh-catalog modules/sudo\",\n \"mesh-catalog modules/supabase\",\n \"mesh-catalog modules/systemd\",\n \"mesh-catalog modules/systemd-networkd\",\n \"mesh-catalog modules/systemd-resolved\",\n \"mesh-catalog modules/telegram\",\n \"mesh-catalog modules/time-sync\",\n \"mesh-catalog modules/triggerhappy\",\n \"mesh-catalog modules/umami\",\n \"mesh-catalog modules/unifi\",\n \"mesh-catalog modules/website\",\n \"mesh-catalog modules/xclip\",\n \"mesh-catalog modules/xdg\",\n \"mesh-catalog modules/xorg\",\n \"mesh-catalog modules/xterm\",\n \"mesh-catalog modules/zfs\",\n \"mesh-catalog modules/zsh\",\n \"mesh-catalog modules/zsh-autosuggestions\",\n \"mesh-catalog modules/zsh-syntax-highlighting\",\n \"mesh-controller\",\n \"mesh-host\",\n \"mesh-lab\",\n \"mesh-media-catalog\",\n \"mesh-sdk\",\n \"mesh-tools\"\n ],\n \"duplicate-check\": \"on\",\n \"judge-model\": \"sonnet\",\n \"forge-owner\": \"novox\",\n \"stuck-after\": \"2h\",\n \"worker-stale-after\": \"24h\"\n}\n" + "content": "{\n \"first-number\": 365,\n \"owners\": [\n \"hq\",\n \"mesh-catalog\",\n \"mesh-catalog modules/adwaita\",\n \"mesh-catalog modules/artifact-store-tools\",\n \"mesh-catalog modules/asus-zephyrus-g14\",\n \"mesh-catalog modules/audit-logger\",\n \"mesh-catalog modules/avahi\",\n \"mesh-catalog modules/baserow\",\n \"mesh-catalog modules/betterbird\",\n \"mesh-catalog modules/blueman\",\n \"mesh-catalog modules/bluetooth\",\n \"mesh-catalog modules/build-agent\",\n \"mesh-catalog modules/ca-trust\",\n \"mesh-catalog modules/claude-code\",\n \"mesh-catalog modules/claude-licence-manager\",\n \"mesh-catalog modules/clipmenu\",\n \"mesh-catalog modules/confluence\",\n \"mesh-catalog modules/cups\",\n \"mesh-catalog modules/dbus\",\n \"mesh-catalog modules/de-spiegel\",\n \"mesh-catalog modules/desk-channel\",\n \"mesh-catalog modules/disk-load\",\n \"mesh-catalog modules/distribution\",\n \"mesh-catalog modules/dmenu\",\n \"mesh-catalog modules/dnsmasq\",\n \"mesh-catalog modules/docker\",\n \"mesh-catalog modules/docker-compose\",\n \"mesh-catalog modules/dunst\",\n \"mesh-catalog modules/fail2ban\",\n \"mesh-catalog modules/feh\",\n \"mesh-catalog modules/flatpak\",\n \"mesh-catalog modules/fonts\",\n \"mesh-catalog modules/forticlient\",\n \"mesh-catalog modules/gitea\",\n \"mesh-catalog modules/gitlab\",\n \"mesh-catalog modules/gnome-keyring\",\n \"mesh-catalog modules/grafana\",\n \"mesh-catalog modules/hello-web\",\n \"mesh-catalog modules/home-assistant\",\n \"mesh-catalog modules/hostname\",\n \"mesh-catalog modules/i3\",\n \"mesh-catalog modules/i3status-rust\",\n \"mesh-catalog modules/icecast\",\n \"mesh-catalog modules/influxdb\",\n \"mesh-catalog modules/invoicing\",\n \"mesh-catalog modules/jira\",\n \"mesh-catalog modules/keycloak\",\n \"mesh-catalog modules/lab\",\n \"mesh-catalog modules/lemurs\",\n \"mesh-catalog modules/letta\",\n \"mesh-catalog modules/local-model-consumer\",\n \"mesh-catalog modules/localization\",\n \"mesh-catalog modules/logrotate\",\n \"mesh-catalog modules/mailu\",\n \"mesh-catalog modules/marrytts\",\n \"mesh-catalog modules/matrix\",\n \"mesh-catalog modules/memory-pressure\",\n \"mesh-catalog modules/mesh-catalog\",\n \"mesh-catalog modules/mesh-cli\",\n \"mesh-catalog modules/mesh-delivery\",\n \"mesh-catalog modules/mesh-issues\",\n \"mesh-catalog modules/mesh-vault\",\n \"mesh-catalog modules/mesh-watcher\",\n \"mesh-catalog modules/messenger\",\n \"mesh-catalog modules/minio\",\n \"mesh-catalog modules/model-usage\",\n \"mesh-catalog modules/mongodb\",\n \"mesh-catalog modules/mosquitto\",\n \"mesh-catalog modules/mounts\",\n \"mesh-catalog modules/mssql\",\n \"mesh-catalog modules/n8n\",\n \"mesh-catalog modules/nats\",\n \"mesh-catalog modules/netcheck\",\n \"mesh-catalog modules/networkmanager\",\n \"mesh-catalog modules/nextcloud\",\n \"mesh-catalog modules/nextcloud-client\",\n \"mesh-catalog modules/nfs-server\",\n \"mesh-catalog modules/nftables\",\n \"mesh-catalog modules/nm-applet\",\n \"mesh-catalog modules/node-env\",\n \"mesh-catalog modules/nodered\",\n \"mesh-catalog modules/ollama\",\n \"mesh-catalog modules/only-office\",\n \"mesh-catalog modules/openai-consumer\",\n \"mesh-catalog modules/openrazer\",\n \"mesh-catalog modules/pacman\",\n \"mesh-catalog modules/photos-eef\",\n \"mesh-catalog modules/photos-filip\",\n \"mesh-catalog modules/picom\",\n \"mesh-catalog modules/polychromatic\",\n \"mesh-catalog modules/postgres\",\n \"mesh-catalog modules/power\",\n \"mesh-catalog modules/powerlevel10k\",\n \"mesh-catalog modules/records\",\n \"mesh-catalog modules/redis\",\n \"mesh-catalog modules/restic\",\n \"mesh-catalog modules/rofi\",\n \"mesh-catalog modules/route-adapter\",\n \"mesh-catalog modules/route-proxy\",\n \"mesh-catalog modules/screen-lock\",\n \"mesh-catalog modules/searxng\",\n \"mesh-catalog modules/sensors\",\n \"mesh-catalog modules/showcase\",\n \"mesh-catalog modules/slack\",\n \"mesh-catalog modules/snapd\",\n \"mesh-catalog modules/ssh-client\",\n \"mesh-catalog modules/sshd\",\n \"mesh-catalog modules/step-ca\",\n \"mesh-catalog modules/sudo\",\n \"mesh-catalog modules/supabase\",\n \"mesh-catalog modules/systemd\",\n \"mesh-catalog modules/systemd-networkd\",\n \"mesh-catalog modules/systemd-resolved\",\n \"mesh-catalog modules/telegram\",\n \"mesh-catalog modules/time-sync\",\n \"mesh-catalog modules/triggerhappy\",\n \"mesh-catalog modules/umami\",\n \"mesh-catalog modules/unifi\",\n \"mesh-catalog modules/website\",\n \"mesh-catalog modules/xclip\",\n \"mesh-catalog modules/xdg\",\n \"mesh-catalog modules/xorg\",\n \"mesh-catalog modules/xterm\",\n \"mesh-catalog modules/zfs\",\n \"mesh-catalog modules/zsh\",\n \"mesh-catalog modules/zsh-autosuggestions\",\n \"mesh-catalog modules/zsh-syntax-highlighting\",\n \"mesh-controller\",\n \"mesh-host\",\n \"mesh-lab\",\n \"mesh-media-catalog\",\n \"mesh-sdk\",\n \"mesh-tools\"\n ],\n \"duplicate-check\": \"on\",\n \"judge-model\": \"sonnet\",\n \"forge-owner\": \"novox\",\n \"stuck-after\": \"2h\",\n \"worker-stale-after\": \"24h\",\n \"delivered-not-resolved-after\": \"1h\",\n \"decision-floor\": 293,\n \"research-floor\": 45\n}\n" } ], "build": { diff --git a/testdata/beside/mesh-catalog/modules/mesh-knowledge/module.json.captured b/testdata/beside/mesh-catalog/modules/mesh-knowledge/module.json.captured new file mode 100644 index 00000000..dbb08b00 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-knowledge/module.json.captured @@ -0,0 +1,186 @@ +{ + "module": "mesh-knowledge", + "version": "1", + "slug": "knowledge", + "requires": [ + "postgres-database" + ], + "contributes": { + "postgres-database": { + "name": "knowledge" + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret" + }, + "seats": [ + { + "name": "knowledge-base", + "scope": "mesh", + "serves": [ + "ask", + "get", + "holds", + "links", + "history", + "reserve", + "propose", + "import", + "return" + ] + } + ], + "claims": [ + { + "name": "knowledge-base", + "scope": "mesh", + "serves": [ + "ask", + "get", + "holds", + "links", + "history", + "reserve", + "propose", + "import", + "return" + ] + } + ], + "tools": [ + "knowledge_health", + "knowledge_highest" + ], + "emits": [ + "reserved", + "proposed", + "reviewed", + "accepted", + "changed", + "imported", + "drafted", + "confirmed", + "returned" + ], + "uses": [ + "operator-channel" + ], + "invokes": [ + "seat:issue-tracker.reserve", + "claude-code.claude_code_judge", + "claude-code.claude_code_judged", + "records.records_status", + "records.records_list", + "records.records_read", + "seat:operator-channel.notify" + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": "." + }, + { + "id": "database-url", + "type": "file", + "path": "${dir:state}/database.url", + "mode": "0600", + "content": "postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n" + }, + { + "id": "settings", + "trusted": false, + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "merge": "json", + "content": "{\n \"judge-model\": \"sonnet\",\n \"drafting\": \"on\",\n \"reserved-for\": \"168h\"\n}\n" + }, + { + "id": "prepare", + "type": "process", + "name": "mesh-knowledge-prepare", + "artifact": "tools", + "run": [ + "./mesh-knowledge", + "prepare" + ], + "run-once": true, + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url" + }, + "restart-on": [ + "database-url" + ] + }, + { + "id": "keep", + "type": "process", + "name": "mesh-knowledge-keep", + "artifact": "tools", + "run": [ + "./mesh-knowledge", + "keep" + ], + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url" + }, + "restart-on": [ + "database-url" + ], + "health": { + "kind": "unit" + } + }, + { + "id": "watch", + "type": "process", + "name": "mesh-knowledge-watch", + "artifact": "tools", + "run": [ + "./mesh-knowledge", + "watch" + ], + "user": "${machine:account}", + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url", + "MESH_KNOWLEDGE_STATUS": "${dir:state}/health.json" + }, + "restart-on": [ + "database-url" + ], + "health": { + "kind": "tool", + "tool": "knowledge_health", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "180s" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-knowledge", + "binary": "mesh-knowledge", + "loads": [ + "mesh-knowledge" + ], + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url", + "MESH_KNOWLEDGE_SETTINGS": "${dir:state}/settings.json", + "MESH_KNOWLEDGE_STATUS": "${dir:state}/health.json" + } + } + ] + } +} diff --git a/testdata/beside/mesh-catalog/modules/mesh-reads/module.json.captured b/testdata/beside/mesh-catalog/modules/mesh-reads/module.json.captured new file mode 100644 index 00000000..4a664485 --- /dev/null +++ b/testdata/beside/mesh-catalog/modules/mesh-reads/module.json.captured @@ -0,0 +1,54 @@ +{ + "module": "mesh-reads", + "version": "1", + "runs-as": "mesh-reads", + "claims": [ + { + "name": "node-reads", + "scope": "node", + "serves": [ + "stat", + "readlink", + "list", + "read", + "search", + "account" + ] + } + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-reads", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-reads", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-reads" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-reads", + "place": "." + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-reads", + "binary": "mesh-reads", + "loads": [ + "mesh-reads" + ] + } + ] + } +} diff --git a/testdata/beside/mesh-catalog/modules/messenger/module.json.captured b/testdata/beside/mesh-catalog/modules/messenger/module.json.captured index b1fde436..3a12a074 100644 --- a/testdata/beside/mesh-catalog/modules/messenger/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/messenger/module.json.captured @@ -73,6 +73,7 @@ "mesh-controller.condition-raised", "mesh-controller.condition-changed", "mesh-controller.condition-cleared", + "mesh-controller.plan-moved", "intake.choice", "intake.link", "intake.failed", @@ -93,7 +94,8 @@ "name": "asks", "ttl-seconds": 2592000 }, - "identities" + "identities", + "walks" ], "own-secrets": { "broker": "${dir:state}/broker" @@ -129,7 +131,7 @@ "path": "${dir:state}/settings.json", "mode": "0600", "merge": "json", - "content": "{\n \"time-zone\": \"\"\n}\n", + "content": "{\n \"time-zone\": \"\",\n \"only-what-needs-me\": []\n}\n", "owner": "messenger" } ], diff --git a/testdata/beside/mesh-catalog/modules/netcheck/module.json.captured b/testdata/beside/mesh-catalog/modules/netcheck/module.json.captured index c8f87901..ec01003a 100644 --- a/testdata/beside/mesh-catalog/modules/netcheck/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/netcheck/module.json.captured @@ -1,7 +1,13 @@ { "module": "netcheck", "version": "1", + "emits": [ + "tunnel.endpoint-changed", + "tunnel.handshake-stale", + "tunnel.handshake-fresh" + ], "tools": [ + "netcheck_tunnel", "netcheck_tcp", "netcheck_dns", "netcheck_http", @@ -16,6 +22,10 @@ "netcheck_dns": [ "dig", "nslookup" + ], + "netcheck_tunnel": [ + "wg show", + "wg show mesh0 dump" ] }, "build": { diff --git a/testdata/beside/mesh-catalog/modules/restic/module.json.captured b/testdata/beside/mesh-catalog/modules/restic/module.json.captured index 9e81a434..604ce0c7 100644 --- a/testdata/beside/mesh-catalog/modules/restic/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/restic/module.json.captured @@ -12,6 +12,11 @@ ] } ], + "tools": [ + "backup_health", + "backup_delete_retired", + "backup_deleted" + ], "own-secrets": { "repository": "${dir:state}/repository.secret" }, @@ -32,6 +37,28 @@ } ] }, + "settings": { + "keep-daily": { + "kind": "preference", + "default": 14, + "why": "two weeks of nightly restore points undo a mistake noticed within a fortnight; a count from 1 to 9999, read by the night, which thins nothing while it does not read (novox/hq issue 416); lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone" + }, + "keep-weekly": { + "kind": "preference", + "default": 8, + "why": "two months of weekly restore points behind the dailies; a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone" + }, + "keep-monthly": { + "kind": "preference", + "default": 6, + "why": "half a year of monthly restore points behind the weeklies; a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone" + }, + "keep-hand-taken-days": { + "kind": "preference", + "default": 7, + "why": "a restore point taken by hand (node-backup.now, or the mesh's before a module moves) is kept until a later good night of its module exists, and this many days in any case, so the next thinning never takes the one taken against a risky change (novox/hq issue 453); a count from 1 to 9999, refused by the night like keep-daily; lowering it lets go of the restore points taken by hand that are older than the new count, past a later night, at the next thinning, and that cannot be undone" + } + }, "resources": [ { "id": "state", @@ -58,6 +85,14 @@ "mode": "0600", "content": "# The data the modules on this machine declare, composed by the mesh (novox/hq ADR 0233). Do not edit.\n${contribution:node-backup:data}" }, + { + "id": "retention", + "type": "file", + "path": "${dir:state}/retention.conf", + "mode": "0644", + "trusted": true, + "content": "# How many restore points the night keeps, written by the mesh from this machine's settings keep-daily,\n# keep-weekly, keep-monthly (novox/hq issue 416) and keep-hand-taken-days (novox/hq issue 453). Replaced on every push; change the settings, never this file.\ndaily=${setting:keep-daily}\nweekly=${setting:keep-weekly}\nmonthly=${setting:keep-monthly}\nhand=${setting:keep-hand-taken-days}\n" + }, { "id": "tool", "type": "package", @@ -67,6 +102,53 @@ "id": "sqlite", "type": "package", "package": "sqlite" + }, + { + "id": "night", + "type": "process", + "name": "restic-night", + "artifact": "tools", + "run": [ + "./restic-backups", + "night" + ], + "user": "${machine:account}", + "env": { + "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf", + "MESH_BACKUP_REPOSITORY": "${dir:repository}", + "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret", + "MESH_BACKUP_STATE": "${dir:state}", + "MESH_BACKUP_DATA": "${dir:state}/data.conf" + }, + "health": { + "kind": "tool", + "tool": "backup_health", + "interval": "60s", + "timeout": "20s", + "looks": 2, + "grace": "60s" + } + }, + { + "id": "measure", + "type": "process", + "name": "restic-measure", + "artifact": "tools", + "run": [ + "./restic-backups", + "measure" + ], + "user": "${machine:account}", + "env": { + "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf", + "MESH_BACKUP_REPOSITORY": "${dir:repository}", + "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret", + "MESH_BACKUP_STATE": "${dir:state}", + "MESH_BACKUP_DATA": "${dir:state}/data.conf" + }, + "health": { + "kind": "unit" + } } ], "build": { diff --git a/testdata/beside/mesh-catalog/modules/route-proxy/module.json.captured b/testdata/beside/mesh-catalog/modules/route-proxy/module.json.captured index 548695a4..8b95306b 100644 --- a/testdata/beside/mesh-catalog/modules/route-proxy/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/route-proxy/module.json.captured @@ -179,7 +179,15 @@ "internal-trust", "internal-acme-env" ], - "logging": "journald" + "logging": "journald", + "health": { + "kind": "tcp", + "endpoint": "http", + "interval": "10s", + "timeout": "5s", + "looks": 3, + "grace": "60s" + } } ], "build": { diff --git a/testdata/beside/mesh-catalog/modules/searxng/module.json.captured b/testdata/beside/mesh-catalog/modules/searxng/module.json.captured index 647fbd38..f6951bdf 100644 --- a/testdata/beside/mesh-catalog/modules/searxng/module.json.captured +++ b/testdata/beside/mesh-catalog/modules/searxng/module.json.captured @@ -76,6 +76,18 @@ "path": "${dir:state}/settings.yml", "mode": "0600", "merge": "json", + "takes-settings": [ + "general", + "brand", + "search", + "server", + "ui", + "outgoing", + "engines", + "plugins", + "categories_as_tabs", + "preferences" + ], "content": "{\n \"use_default_settings\": true,\n \"server\": {\n \"secret_key\": \"${secret:secret}\",\n \"base_url\": false,\n \"limiter\": false,\n \"image_proxy\": false,\n \"public_instance\": false\n },\n \"search\": {\n \"formats\": [\"html\", \"json\"]\n },\n \"valkey\": {\n \"url\": \"valkey://valkey:6379/0\"\n }\n}\n" }, {