From 8c587d0f00b6c7342e5dbc3a4a9dc049697fc62a Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 18 Oct 2026 16:52:29 +0200 Subject: [PATCH] =?UTF-8?q?Let=20the=20repositories=20pass's=20failed=20ex?= =?UTF-8?q?it=20be=20its=20gate:=20a=20run-once=20step=20the=20node-engine?= =?UTF-8?q?=20runs=20again=20until=20it=20passes,=20its=20stderr=20naming?= =?UTF-8?q?=20each=20refused=20repository,=20step=20and=20forge=20answer,?= =?UTF-8?q?=20instead=20of=20a=20health=20check=20of=20its=20own=20(issue?= =?UTF-8?q?=201124,=20ADR=200428=20=C2=A76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/gitea/README.md | 16 +++- modules/gitea/cmd/repositories/health.go | 82 ------------------- modules/gitea/cmd/repositories/main.go | 68 +++++---------- .../gitea/cmd/repositories/manifest_test.go | 12 ++- modules/gitea/cmd/repositories/outcome.go | 79 ++++++++++++++++++ .../gitea/cmd/repositories/repositories.go | 2 +- .../cmd/repositories/repositories_test.go | 63 +++++++++----- modules/gitea/module.json | 5 +- 8 files changed, 168 insertions(+), 159 deletions(-) delete mode 100644 modules/gitea/cmd/repositories/health.go create mode 100644 modules/gitea/cmd/repositories/outcome.go diff --git a/modules/gitea/README.md b/modules/gitea/README.md index 002f0c57..c11629d5 100644 --- a/modules/gitea/README.md +++ b/modules/gitea/README.md @@ -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: : refused: `, 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. diff --git a/modules/gitea/cmd/repositories/health.go b/modules/gitea/cmd/repositories/health.go deleted file mode 100644 index 55bfdcce..00000000 --- a/modules/gitea/cmd/repositories/health.go +++ /dev/null @@ -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))) -} diff --git a/modules/gitea/cmd/repositories/main.go b/modules/gitea/cmd/repositories/main.go index f8018c41..6a1a4c8e 100644 --- a/modules/gitea/cmd/repositories/main.go +++ b/modules/gitea/cmd/repositories/main.go @@ -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 } diff --git a/modules/gitea/cmd/repositories/manifest_test.go b/modules/gitea/cmd/repositories/manifest_test.go index bca2d014..012ef8ca 100644 --- a/modules/gitea/cmd/repositories/manifest_test.go +++ b/modules/gitea/cmd/repositories/manifest_test.go @@ -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"]) } diff --git a/modules/gitea/cmd/repositories/outcome.go b/modules/gitea/cmd/repositories/outcome.go new file mode 100644 index 00000000..36a672d7 --- /dev/null +++ b/modules/gitea/cmd/repositories/outcome.go @@ -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: : refused: +// ", 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 +} diff --git a/modules/gitea/cmd/repositories/repositories.go b/modules/gitea/cmd/repositories/repositories.go index b15b34fd..f62a5048 100644 --- a/modules/gitea/cmd/repositories/repositories.go +++ b/modules/gitea/cmd/repositories/repositories.go @@ -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"` diff --git a/modules/gitea/cmd/repositories/repositories_test.go b/modules/gitea/cmd/repositories/repositories_test.go index 773b0873..6c4cf096 100644 --- a/modules/gitea/cmd/repositories/repositories_test.go +++ b/modules/gitea/cmd/repositories/repositories_test.go @@ -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", 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"}}} diff --git a/modules/gitea/module.json b/modules/gitea/module.json index 04711e2b..c8a8c6c9 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -508,10 +508,7 @@ "binary": "repositories", "loads": [ "repositories" - ], - "env": { - "MESH_REPOSITORIES_OUTCOME": "${dir:runtime-state}/repositories-outcome.json" - } + ] }, { "name": "forge-account-provider",