Let the repositories pass's failed exit be its gate: a run-once step the node-engine runs again until it passes, its stderr naming each refused repository, step and forge answer, instead of a health check of its own (issue 1124, ADR 0428 §6)

This commit is contained in:
2026-10-18 16:52:29 +02:00
parent df275b3c6e
commit 8c587d0f00
8 changed files with 168 additions and 159 deletions
-82
View File
@@ -1,82 +0,0 @@
package main
// The pass's health (ADR 0428 §6, ADR 0240), judged from the outcome it left, as restic-backups judges its nights
// (modules/restic/cmd/restic-backups/health.go Judge): unhealthy when a step of a listed repository was refused,
// naming the repository, the step and the forge's answer; when the pass could not run; or when no pass has left an
// outcome. Read through the tool repositories_health, which a health check of kind tool asks.
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/health"
)
// WriteOutcome keeps the outcome where the health check reads it, whole or not at all.
func WriteOutcome(file string, o Outcome) error {
raw, err := json.MarshalIndent(o, "", " ")
if err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(file), ".repositories-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(append(raw, '\n')); err != nil {
tmp.Close()
return err
}
if err := tmp.Chmod(0o644); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), file)
}
// ReadOutcome reads what the last pass left.
func ReadOutcome(file string) (*Outcome, error) {
raw, err := os.ReadFile(file)
if err != nil {
return nil, err
}
var o Outcome
if err := json.Unmarshal(raw, &o); err != nil {
return nil, fmt.Errorf("the outcome %s is not readable: %w", file, err)
}
return &o, nil
}
func clock(t time.Time) string { return t.Local().Format("2006-01-02 15:04") }
// Judge is the health of what the last pass left. Pure.
func Judge(o *Outcome, readErr error, now time.Time) health.Answer {
switch {
case errors.Is(readErr, os.ErrNotExist):
return health.Unhealthy("no pass over the config value repositories has left its outcome yet; the module runs " +
"one at its start: read the journal of gitea-repositories (node-service-manager.journal)")
case readErr != nil:
return health.Unhealthy(fmt.Sprintf("what the last pass over the config value repositories left does not read (%v)", readErr))
case o.Failed != "":
return health.Unhealthy(fmt.Sprintf("the pass of %s over the config value repositories did not run: %s. Nothing "+
"was made; it runs again when the module starts or the value changes", clock(o.At), o.Failed))
}
var said []string
for _, r := range o.Refused() {
said = append(said, fmt.Sprintf("%s/%s: the step %s was refused by the forge (%s)", o.Org, r.Name, r.Step, r.Said))
}
if len(said) > 0 {
return health.Unhealthy(strings.Join(said, "; ") + ". Nothing further of it was done; the next pass (the " +
"module's start, or a change of the value) takes it up again")
}
return health.Healthy(fmt.Sprintf("the %d repositories the config value lists are made, their main protected (pass of %s)",
len(o.Repositories), clock(o.At)))
}
+21 -47
View File
@@ -1,14 +1,12 @@
// repositories: the organisation's repositories from the gitea module's config value `repositories` (novox/hq
// ADR 0428, issue 1124). One binary, two ways to run, as restic-backups is (modules/restic/cmd/restic-backups):
//
// - `sync`, a process the node-engine runs when the module starts and again whenever the value changes: it reads
// the file the mesh renders from that value and, with the setup login, makes every listed repository that does
// not exist under that login's own account, protects its main, writes main's first commit, narrows main's rule
// and only then moves it into the organisation; one that exists only has its main rule put right. A repository
// the value does not list is never touched. It keeps what it did, per repository, in the outcome file, says each
// change on stdout in one line and never a secret, and exits 1 when a step was refused;
// - with no argument, launched by the machine's tool runtime and speaking MCP to it over stdio: the tool
// repositories_health, the health of the last pass, judged from the outcome file (health.go).
// ADR 0428, issue 1124). `repositories sync` is a run-once process the node-engine runs when the module starts and
// again whenever the value changes: it reads the file the mesh renders from that value and, with the setup login,
// makes every listed repository that does not exist under that login's own account, protects its main, writes
// main's first commit, narrows main's rule and only then moves it into the organisation; one that exists only has
// its main rule put right. A repository the value does not list is never touched. It keeps what it did, per
// repository, in the outcome file, says each change on stdout in one line and never a secret, and exits 1 when a
// step was refused, its last stderr lines naming each (outcome.go Report): the node-engine's gate on a run-once
// step (ADR 0136) reports it and runs it again on every apply until it passes.
package main
import (
@@ -17,35 +15,15 @@ import (
"os"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"gitea/internal/forgehttp"
)
func main() {
if len(os.Args) > 1 && os.Args[1] == "sync" {
os.Exit(runSync())
if len(os.Args) != 2 || os.Args[1] != "sync" {
fmt.Fprintln(os.Stderr, "usage: repositories sync")
os.Exit(2)
}
if err := stdio.Serve("", Tools(os.Getenv("MESH_REPOSITORIES_OUTCOME"))); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// Tools are the bundle's tools: the health of the last pass.
func Tools(outcome string) []stdio.Tool {
return []stdio.Tool{{
Name: "repositories_health",
Description: "Whether the organisation's repositories the gitea module's config value `repositories` lists " +
"are made and their main protected (novox/hq ADR 0428): unhealthy, naming the repository, the step and " +
"the forge's answer, when the last pass had a step refused, or when no pass has run. (r)",
Run: func(map[string]any) (any, error) {
if outcome == "" {
return nil, fmt.Errorf("MESH_REPOSITORIES_OUTCOME is not set by the manifest")
}
o, err := ReadOutcome(outcome)
return Judge(o, err, time.Now()), nil
},
}}
os.Exit(runSync())
}
func runSync() int {
@@ -73,23 +51,19 @@ func runSync() int {
WaitFor: 3 * time.Minute, Poll: 5 * time.Second}
value, err := ReadValue(file)
if err != nil {
fmt.Fprintln(os.Stderr, "repositories:", err)
_ = WriteOutcome(outcome, Outcome{At: time.Now().UTC(), Org: s.Org, Failed: err.Error()})
return 1
o := Outcome{At: time.Now().UTC(), Org: s.Org, Failed: err.Error()}
_ = WriteOutcome(outcome, o)
return Report(o, os.Stderr)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
o, err := s.Sync(ctx, value)
if werr := WriteOutcome(outcome, o); werr != nil {
o, _ := s.Sync(ctx, value) // a forge not reachable is in o.Failed
werr := WriteOutcome(outcome, o)
if werr != nil {
fmt.Fprintln(os.Stderr, "repositories: the outcome could not be kept:", werr)
return 1
}
if err != nil {
fmt.Fprintln(os.Stderr, "repositories:", err)
return 1
if code := Report(o, os.Stderr); code != 0 || werr == nil {
return code
}
if len(o.Refused()) > 0 {
return 1
}
return 0
return 1
}
@@ -9,7 +9,8 @@ import (
// 1124/AC1: the manifest declares `repositories` under config as a trusted object value with an empty default, as
// it declares agent-read-teams (ADR 0365 §3, ADR 0348), renders it into a file of its own, and runs this program
// at the module's start and whenever that file changes.
// at the module's start and whenever that file changes, as a run-once process (1124/AC6: its failed exit is the
// node-engine's gate, ADR 0136) declared last, so that gate stops nothing of the module after it.
func TestManifestDeclaresTheValueAndItsTrigger(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
@@ -51,6 +52,15 @@ func TestManifestDeclaresTheValueAndItsTrigger(t *testing.T) {
if process == nil {
t.Fatalf("no process runs again when %s changes", file["id"])
}
if process["run-once"] != true {
t.Errorf("the process is not run-once: %v", process)
}
if last := m.Resources[len(m.Resources)-1]; last["id"] != process["id"] {
t.Errorf("the last resource is %v, want %v: a refused step gates whatever of the module comes after it", last["id"], process["id"])
}
if process["health"] != nil {
t.Errorf("the process declares a health check: %v", process["health"])
}
if strings.Join(strs(process["run"]), " ") != "./repositories sync" || process["artifact"] != "repositories" {
t.Errorf("the process runs %v from %v", process["run"], process["artifact"])
}
+79
View File
@@ -0,0 +1,79 @@
package main
// What a pass left (ADR 0428 §6): the outcome file, and the pass's exit. A refused step is not judged by a health
// check of its own: the sync is a run-once process, so its non-zero exit is the node-engine's gate (ADR 0136). The
// module's step is reported failed, apply-failing is raised, and every apply runs it again until it passes, which is
// the "next pass" of §6. Its last stderr lines name each refused repository, its step and the forge's answer.
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// WriteOutcome keeps what the pass did per repository, whole or not at all.
func WriteOutcome(file string, o Outcome) error {
raw, err := json.MarshalIndent(o, "", " ")
if err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(file), ".repositories-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(append(raw, '\n')); err != nil {
tmp.Close()
return err
}
if err := tmp.Chmod(0o644); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), file)
}
// ReadOutcome reads what the last pass left.
func ReadOutcome(file string) (*Outcome, error) {
raw, err := os.ReadFile(file)
if err != nil {
return nil, err
}
var o Outcome
if err := json.Unmarshal(raw, &o); err != nil {
return nil, fmt.Errorf("the outcome %s is not readable: %w", file, err)
}
return &o, nil
}
// Report says on stderr what the pass refused and is the pass's exit: 0 when every listed repository was made, put
// right or already right; 1 otherwise. One line per refused repository, "repositories: <name>: <step> refused:
// <forge answer>", then a summary line, so the line the node-engine keeps last names what to look at.
func Report(o Outcome, stderr io.Writer) int {
if o.Failed != "" {
fmt.Fprintf(stderr, "repositories: the pass did not run: %s; nothing was made\n", o.Failed)
return 1
}
refused := o.Refused()
if len(refused) == 0 {
return 0
}
for _, r := range refused {
fmt.Fprintf(stderr, "repositories: %s: %s refused: %s\n", r.Name, r.Step, r.Said)
}
if len(refused) > 1 {
names := make([]string, 0, len(refused))
for _, r := range refused {
names = append(names, r.Name)
}
fmt.Fprintf(stderr, "repositories: %d of %d repositories had a step refused (%s); nothing further of them was done, the rest were\n",
len(refused), len(o.Repositories), strings.Join(names, ", "))
}
return 1
}
@@ -96,7 +96,7 @@ type Result struct {
Said string `json:"said,omitempty"`
}
// Outcome is what a pass left, for the health check to judge (health.go).
// Outcome is what a pass left, kept in the outcome file and said on stderr (outcome.go).
type Outcome struct {
At time.Time `json:"at"`
Org string `json:"org"`
@@ -1,8 +1,8 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
@@ -215,7 +215,7 @@ func TestStatusesAreAddedAndNeverDropped(t *testing.T) {
}
// 1124/AC6: a refused step is said with the repository, the step and the forge's answer, nothing further of that
// repository is sent, and the next listed repository is still made; the health check then is unhealthy naming them.
// repository is sent, and the next listed repository is still made; the pass exits 1, its stderr naming them.
func TestRefusedStepStopsThatRepositoryOnly(t *testing.T) {
steps := map[string]func(method, path string) bool{
StepCreate: func(m, p string) bool { return m == "POST" && p == "/api/v1/user/repos" },
@@ -267,10 +267,14 @@ func TestRefusedStepStopsThatRepositoryOnly(t *testing.T) {
if f.repos["novox/b"] == nil || result(o, "b").Did != DidMade {
t.Errorf("b was not made after a's refusal: %+v", result(o, "b"))
}
h := Judge(&o, nil, time.Now())
if h.Healthy || !strings.Contains(h.Why, "novox/a") || !strings.Contains(h.Why, step) ||
!strings.Contains(h.Why, "the forge says no to "+step) {
t.Errorf("health %+v, want unhealthy naming novox/a, %s and the answer", h, step)
var stderr bytes.Buffer
if code := Report(o, &stderr); code == 0 {
t.Errorf("a refused %s exits 0", step)
}
lines := strings.Split(strings.TrimRight(stderr.String(), "\n"), "\n")
if want := "repositories: a: " + step + " refused: "; len(lines) != 1 || !strings.HasPrefix(lines[0], want) ||
!strings.Contains(lines[0], "the forge says no to "+step) {
t.Errorf("stderr %q, want one line %q<the forge's answer>", stderr.String(), want)
}
})
}
@@ -294,27 +298,46 @@ func TestRepositoryOutOfTheValueIsNotTouched(t *testing.T) {
}
}
// 1124/AC6: the outcome the pass leaves is what the health check judges: healthy when every repository was made or
// put right, unhealthy naming each refusal, and unhealthy when no pass has left one.
func TestJudge(t *testing.T) {
// 1124/AC6: the pass's exit is the node-engine's gate on a run-once step (ADR 0136): 0 when every repository was
// made, put right or unchanged and nothing on stderr; 1 with one line per refused repository, then a summary line
// naming them all, when several were; 1 naming why when the pass did not run.
func TestReportIsTheExitAndTheStderrLines(t *testing.T) {
now := time.Now()
ok := Outcome{At: now, Repositories: []Result{{Name: "hq", Did: DidUnchanged}, {Name: "x", Did: DidMade}}}
if h := Judge(&ok, nil, now); !h.Healthy || h.State != "healthy" {
t.Errorf("%+v", h)
var stderr bytes.Buffer
ok := Outcome{At: now, Repositories: []Result{{Name: "hq", Did: DidUnchanged}, {Name: "x", Did: DidMade}, {Name: "y", Did: DidPutRight}}}
if code := Report(ok, &stderr); code != 0 || stderr.Len() != 0 {
t.Errorf("all made: exit %d, stderr %q", code, stderr.String())
}
if h := Judge(nil, os.ErrNotExist, now); h.Healthy || !strings.Contains(h.Why, "no pass") {
t.Errorf("no outcome: %+v", h)
stderr.Reset()
two := Outcome{At: now, Repositories: []Result{
{Name: "a", Did: DidRefused, Step: StepNarrow, Said: "403 Forbidden: user does not have permission"},
{Name: "b", Did: DidMade},
{Name: "c", Did: DidRefused, Step: StepMove, Said: "422: cannot transfer"},
}}
if code := Report(two, &stderr); code != 1 {
t.Errorf("two refused: exit %d", code)
}
if h := Judge(nil, errors.New("broken"), now); h.Healthy {
t.Errorf("unreadable outcome: %+v", h)
lines := strings.Split(strings.TrimRight(stderr.String(), "\n"), "\n")
want := []string{
"repositories: a: narrow refused: 403 Forbidden: user does not have permission",
"repositories: c: move refused: 422: cannot transfer",
}
if h := Judge(&Outcome{At: now, Failed: "the forge is not reachable"}, nil, now); h.Healthy ||
!strings.Contains(h.Why, "the forge is not reachable") {
t.Errorf("a pass that did not run: %+v", h)
if len(lines) != 3 || lines[0] != want[0] || lines[1] != want[1] {
t.Fatalf("stderr:\n%s\nwant the lines:\n%s\nthen a summary", stderr.String(), strings.Join(want, "\n"))
}
if !strings.Contains(lines[2], "2 of 3") || !strings.Contains(lines[2], "a, c") {
t.Errorf("summary %q, want the count and the names", lines[2])
}
stderr.Reset()
if code := Report(Outcome{At: now, Failed: "the forge is not reachable"}, &stderr); code != 1 ||
!strings.Contains(stderr.String(), "the forge is not reachable") {
t.Errorf("a pass that did not run: exit %d, stderr %q", code, stderr.String())
}
}
// 1124/AC6: the outcome is written where the health check reads it, and read back whole.
// 1124/AC6: the outcome is written whole, and read back whole.
func TestOutcomeRoundTrip(t *testing.T) {
p := filepath.Join(t.TempDir(), "outcome.json")
o := Outcome{At: time.Now().UTC().Truncate(time.Second), Repositories: []Result{{Name: "a", Did: DidRefused, Step: StepMove, Said: "no"}}}