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:
+12
-4
@@ -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.
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
|
||||
@@ -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"}}}
|
||||
|
||||
@@ -508,10 +508,7 @@
|
||||
"binary": "repositories",
|
||||
"loads": [
|
||||
"repositories"
|
||||
],
|
||||
"env": {
|
||||
"MESH_REPOSITORIES_OUTCOME": "${dir:runtime-state}/repositories-outcome.json"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "forge-account-provider",
|
||||
|
||||
Reference in New Issue
Block a user