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
+12 -4
View File
@@ -159,10 +159,18 @@ listed repository:
- one in the organisation is only put right: main's rule narrowed, a missing one of the two statuses added and every
status already there kept.
A step the forge refuses stops that repository until the next pass, and the others go on. A repository taken out
of the value is never asked about: nothing is deleted, renamed, moved out or unprotected. The pass keeps what it did
per repository in `repositories-outcome.json`; the bundle, run by the tool runtime, serves `repositories_health`,
which judges it: unhealthy, naming the repository, the step and the forge's answer, when a step was refused.
A step the forge refuses stops that repository, and the others go on. A repository taken out of the value is never
asked about: nothing is deleted, renamed, moved out or unprotected. The pass keeps what it did per repository in
`repositories-outcome.json`.
**How a refusal is seen.** `repositories sync` is a run-once process, declared as the module's last resource, so
the node-engine's gate on a step that did not complete (ADR 0136) stops nothing of the module after it. When any
step was refused it exits 1, and its last stderr lines are one per refused repository,
`repositories: <name>: <step> refused: <the forge's answer>`, then, when there were several, a summary line naming
them. The node-engine reports the module's step failed and raises apply-failing, and runs it again on every apply
until it passes: that is the next pass. The condition says that the `gitea-repositories` step did not complete;
the lines naming the repository, the step and the forge's answer are in that unit's journal
(`node-service-manager.journal`).
No tool creates in the organisation: `gitea_create_repo` creates under the tools' own user only, the tools' team
keeps `can_create_org_repo` false and its token has no `write:organization` (ADR 0428 §8).
`go test ./cmd/repositories` runs it against a fake forge.
-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"}}}
+1 -4
View File
@@ -508,10 +508,7 @@
"binary": "repositories",
"loads": [
"repositories"
],
"env": {
"MESH_REPOSITORIES_OUTCOME": "${dir:runtime-state}/repositories-outcome.json"
}
]
},
{
"name": "forge-account-provider",