diff --git a/cmd/mesh-controller/build.go b/cmd/mesh-controller/build.go index a69d8379..6f1c0cd5 100644 --- a/cmd/mesh-controller/build.go +++ b/cmd/mesh-controller/build.go @@ -640,7 +640,15 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu // **Only from the repository the catalogue builds the module from** (novox/hq ADR 0266): else the trunk // below is the trunk of whatever repository was built, which may be one an agent made — and a module named // `sudo` from it would be what the next push sends. Another repository is the operator's, at the terminal. - if err := mayRegisterFrom(ctx, inv, manifest.Module, recorded, result.ID); err != nil { + trunk := result.Trunk + if was, err := inv.SourceOf(ctx, manifest.Module); err == nil && followedBranch(was.Ref) != "" { + trunk = followedBranch(was.Ref) + } + if trunk == "" { + trunk = "main" + } + repoID, err := mayRegisterFrom(ctx, inv, manifest.Module, recorded, result.ID, result.Path, trunk) + if err != nil { return manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w", result.On, manifest.Module, short(result.Commit), err) } @@ -691,6 +699,10 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu } return manifest, kept, err } + // Which repository it is registered from, by the forge's own id (novox/hq ADR 0266). + if err := inv.SetSourceIdentity(ctx, manifest.Module, repoID); err != nil { + return manifest, kept, err + } // The keep set just moved, and new bytes just landed (novox/hq ADR 0189). Asked here rather // than on a timer of its own: this is the only moment either is true. Never fatal — the build // worked and the module is registered. diff --git a/cmd/mesh-controller/build_source.go b/cmd/mesh-controller/build_source.go index 2d8b0ae5..053639de 100644 --- a/cmd/mesh-controller/build_source.go +++ b/cmd/mesh-controller/build_source.go @@ -2,10 +2,13 @@ package main import ( "context" + "encoding/json" "errors" "fmt" "os" + "regexp" "strings" + "time" "github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/inventory" @@ -35,11 +38,24 @@ import ( // errNotItsSource is an outcome refused for where it was built from. var errNotItsSource = errors.New("not built from the repository the catalogue builds it from") -// startedAtTheTerminal says this process was started at the controller's terminal: neither a verb nor a seat call -// started it. runVerb sets both for every command a verb runs (seatverbs.go), and a verb is the only way an -// agent reaches the controller. +// servedVar marks every process the serving controller starts — each verb's command, each child — and the serving +// process itself, so none of them can read as the operator at the terminal (novox/hq ADR 0266). Set by serve +// before it answers anything, inherited by every child through os.Environ. +const servedVar = "MESH_SERVED_BY_THE_CONTROLLER" + +// startedAtTheTerminal says this process was started at the controller's terminal: not the serving controller, +// not anything it started, not a verb's command, not a seat call's. The serving controller marks its own +// environment (servedVar), so a build asked in it, or by any process it starts, never reads as the terminal's; +// runVerb also names the verb and the caller. func startedAtTheTerminal() bool { - return os.Getenv(verbVar) == "" && os.Getenv(link.CallerVar) == "" + return os.Getenv(servedVar) == "" && os.Getenv(verbVar) == "" && os.Getenv(link.CallerVar) == "" +} + +// markServed marks this process, and so everything it starts, as the serving controller's. +func markServed() { + if err := os.Setenv(servedVar, "1"); err != nil { + panic("the serving controller could not mark its environment: " + err.Error()) + } } // sourceForms compares sources however each is spelled: a path on a seat's holder (ADR 0111) or a URL — a @@ -116,21 +132,166 @@ func (f *sourceForms) buildsFrom(entries []inventory.Entry, repository, seat str return false } -// mayRegisterFrom says whether a build's outcome may register module from the source it was built from -// (novox/hq ADR 0266): from the module's registered repository; for a module new to the catalogue, from a -// repository the catalogue builds another module from; else only when the build was asked at the terminal. +// forgeFacts is what the mesh's forge says of a repository a module is registered from (novox/hq ADR 0266). +type forgeFacts struct { + // ID is the forge's own id of the repository: what tells it from one deleted and made again by its name. + ID int64 + // Guarded is whether the branch is protected as a module's trunk must be: no direct push, at least one + // required status, and no administrator merging past one. Why says what is missing when it is not. + Guarded bool + Why string +} + +// askTheForge asks the forge, through its module's tools on the bus, for a repository's id and its branch's +// protection. A variable so a test needs no forge. +var askTheForge = func(ctx context.Context, owner, repo, branch string) (forgeFacts, error) { + js, err := aBus() + if err != nil { + return forgeFacts{}, err + } + defer js.Close() + bus := link.OverNATS{Conn: js.Conn()} + ask := func(tool string, args map[string]any, into any) error { + raw, _ := json.Marshal(args) + answer, err := link.Ask(ctx, bus, "gitea", tool, raw, 30*time.Second) + if err != nil { + return err + } + if answer.Error != "" { + return fmt.Errorf("gitea.%s: %s", tool, answer.Error) + } + return json.Unmarshal(answer.Result, into) + } + var found struct { + Result struct { + ID int64 `json:"id"` + FullName string `json:"full_name"` + } `json:"result"` + } + if err := ask("gitea_api", map[string]any{"path": "/repos/" + owner + "/" + repo}, &found); err != nil { + return forgeFacts{}, err + } + if found.Result.ID == 0 { + return forgeFacts{}, fmt.Errorf("the forge named no id for %s/%s", owner, repo) + } + var rules struct { + Rules []struct { + Rule string `json:"rule"` + Push bool `json:"push"` + RequiredStatuses []string `json:"required_statuses"` + AdminMayOverride bool `json:"admin_may_override"` + } `json:"rules"` + } + if err := ask("gitea_branch_protection_get", map[string]any{"owner": owner, "repo": repo}, &rules); err != nil { + return forgeFacts{}, err + } + facts := forgeFacts{ID: found.Result.ID, Why: fmt.Sprintf("no protection rule covers %s", branch)} + for _, r := range rules.Rules { + if !ruleCovers(r.Rule, branch) { + continue + } + switch { + case r.Push: + facts.Why = fmt.Sprintf("the rule %s lets a person push to %s directly", r.Rule, branch) + case len(r.RequiredStatuses) == 0: + facts.Why = fmt.Sprintf("the rule %s requires no status before a merge into %s", r.Rule, branch) + case r.AdminMayOverride: + facts.Why = fmt.Sprintf("the rule %s lets an administrator merge into %s past a status", r.Rule, branch) + default: + return forgeFacts{ID: facts.ID, Guarded: true}, nil + } + } + return facts, nil +} + +// ruleCovers says a protection rule's name — a branch, or a glob of them — covers a branch, as the forge reads it. +func ruleCovers(rule, branch string) bool { + if rule == branch { + return true + } + if !strings.ContainsAny(rule, "*?[") { + return false + } + var re strings.Builder + re.WriteString("^") + for i := 0; i < len(rule); i++ { + switch c := rule[i]; { + case c == '*' && i+1 < len(rule) && rule[i+1] == '*': + re.WriteString(".*") + i++ + case c == '*': + re.WriteString("[^/]*") + case c == '?': + re.WriteString("[^/]") + default: + re.WriteString(regexp.QuoteMeta(string(c))) + } + } + re.WriteString("$") + ok, _ := regexp.MatchString(re.String(), branch) + return ok +} + +// onTheForge is a source's owner and name on the mesh's own forge (the git seat's holder), and whether it is +// there at all. +func (f *sourceForms) onTheForge(repository, seat string) (owner, name string, ok bool) { + var rest string + switch { + case seat == gitSeat: + rest = strings.Trim(repository, "/") + case seat == "": + base := f.base(gitSeat) + if base == "" { + return "", "", false + } + url, prefix := f.canonical(repository, ""), f.canonical(base, "")+"/" + if !strings.HasPrefix(url, prefix) { + return "", "", false + } + // The case the forge spells it with: the URL as given, past the base. + rest = strings.TrimSuffix(strings.Trim(repository[len(prefix):], "/"), ".git") + default: + return "", "", false + } + parts := strings.Split(rest, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", false + } + return parts[0], parts[1], true +} + +// mayRegisterFrom says whether a build's outcome may register module from the source it was built from, and the +// forge's id of that repository to record (novox/hq ADR 0266). Through any verb, only: +// +// - from the module's registered repository — the same repository by the forge's own id, not only its name; or, +// for a module new to the catalogue, from a repository the catalogue builds another module from; +// - and from a repository on the mesh's forge whose trunk is protected as a trunk must be: no direct push, at +// least one required status, no administrator merging past one. +// +// Anything else only when the build request was kept as asked at the controller's terminal, for this very +// repository and path. path is the module's directory as built; branch the trunk it is registered from. func mayRegisterFrom(ctx context.Context, inv *inventory.Inventory, module string, built inventory.Source, - buildID string) error { + buildID, path, branch string) (int64, error) { forms := newSourceForms(ctx, inv) was, err := inv.SourceOf(ctx, module) isNew := errors.Is(err, inventory.ErrNoSuchModule) if err != nil && !isNew { - return err + return 0, err } + terminal, err := askedHereFor(ctx, inv, forms, buildID, built, path) + if err != nil { + return 0, err + } + refuse := func(why string) (int64, error) { + return 0, fmt.Errorf("%w: %s. A module is registered from such a source only by a build asked at the "+ + "controller's terminal, never through a verb: a registered module is what the next push sends, and whoever "+ + "may call a verb includes agents (novox/hq ADR 0266)", errNotItsSource, why) + } + var why string + var alongside []inventory.Entry // the modules a new one's repository already builds switch { case !isNew && forms.same(was.Repository, was.Seat, built.Repository, built.Seat): - return nil case !isNew: registered := was.Repository if registered == "" { @@ -141,25 +302,94 @@ func mayRegisterFrom(ctx context.Context, inv *inventory.Inventory, module strin default: entries, err := inv.Catalogued(ctx) if err != nil { - return err + return 0, err } - if forms.buildsFrom(entries, built.Repository, built.Seat) { - return nil + for _, e := range entries { + if !e.Provided && e.Source.Repository != "" && + forms.same(e.Source.Repository, e.Source.Seat, built.Repository, built.Seat) { + alongside = append(alongside, e) + } + } + if len(alongside) == 0 { + why = fmt.Sprintf("%s is new to the catalogue, and %s is no repository the catalogue builds a module from", + module, sourceWords(built.Repository, built.Seat)) } - why = fmt.Sprintf("%s is new to the catalogue, and %s is no repository the catalogue builds a module from", - module, sourceWords(built.Repository, built.Seat)) } - terminal, err := inv.AskedAtTheTerminal(ctx, buildID) + if why != "" && !terminal { + return refuse(why) + } + + owner, name, onForge := forms.onTheForge(built.Repository, built.Seat) + if !onForge { + if terminal { + fmt.Printf("%s: %s is not on the mesh's forge — registered, as asked at the controller's terminal\n", + buildID, sourceWords(built.Repository, built.Seat)) + return 0, nil + } + return refuse(fmt.Sprintf("%s is not on the mesh's forge, so whether its trunk is protected cannot be read", + sourceWords(built.Repository, built.Seat))) + } + facts, err := askTheForge(ctx, owner, name, branch) if err != nil { - return err + if terminal { + fmt.Printf("%s: the forge could not be asked about %s/%s (%v) — registered, as asked at the controller's "+ + "terminal\n", buildID, owner, name, err) + return 0, nil + } + return refuse(fmt.Sprintf("the forge could not say whether %s/%s's %s is protected: %v", owner, name, branch, err)) } if terminal { - fmt.Printf("%s: %s — registered, as asked at the controller's terminal\n", buildID, why) - return nil + if why != "" || !facts.Guarded { + fmt.Printf("%s: %s — registered, as asked at the controller's terminal\n", buildID, + strings.Trim(why+"; "+facts.Why, "; ")) + } + return facts.ID, nil } - return fmt.Errorf("%w: %s. A module is registered from another repository only by a build asked at the "+ - "controller's terminal, never through a verb: a registered module is what the next push sends, and whoever "+ - "may call a verb includes agents (novox/hq ADR 0266)", errNotItsSource, why) + if !facts.Guarded { + return refuse(fmt.Sprintf("%s/%s's %s is not protected as a module's trunk must be: %s", owner, name, branch, + facts.Why)) + } + // The same repository by the forge's id, not only its name: one deleted and made again is another. + recorded := map[string]int64{} + if !isNew { + id, err := inv.SourceIdentity(ctx, module) + if err != nil { + return 0, err + } + recorded[module] = id + } + for _, e := range alongside { + id, err := inv.SourceIdentity(ctx, e.Manifest.Module) + if err != nil { + return 0, err + } + recorded[e.Manifest.Module] = id + } + for m, id := range recorded { + if id != 0 && id != facts.ID { + return refuse(fmt.Sprintf("%s/%s is not the repository %s was registered from: the forge knows it as "+ + "repository %d, and %s was registered from repository %d — one of that name deleted and made again", + owner, name, m, facts.ID, m, id)) + } + } + return facts.ID, nil +} + +// askedHereFor says the build was asked at the controller's terminal, for this repository and this path: a kept +// request marked so, whose source is the outcome's (novox/hq ADR 0266). +func askedHereFor(ctx context.Context, inv *inventory.Inventory, forms *sourceForms, buildID string, + built inventory.Source, path string) (bool, error) { + r, found, err := inv.BuildRequestByID(ctx, buildID) + if err != nil || !found || !r.AtTerminal { + return false, err + } + if !forms.same(r.Repository, r.Seat, built.Repository, built.Seat) || + strings.Trim(r.Path, "/") != strings.Trim(path, "/") { + fmt.Printf("%s was asked at the terminal of %s at %q, and its outcome is of %s at %q: not the terminal's\n", + buildID, sourceWords(r.Repository, r.Seat), r.Path, sourceWords(built.Repository, built.Seat), path) + return false, nil + } + return true, nil } // sourceWords is a source as a person reads it. diff --git a/cmd/mesh-controller/build_source_test.go b/cmd/mesh-controller/build_source_test.go index 6a9301fc..69fc5d8a 100644 --- a/cmd/mesh-controller/build_source_test.go +++ b/cmd/mesh-controller/build_source_test.go @@ -1,9 +1,15 @@ package main import ( + "context" "encoding/json" "errors" + "hash/fnv" + "os" + "os/exec" + "slices" "strings" + "sync" "testing" "github.com/novox/mesh-controller/internal/inventory" @@ -28,10 +34,10 @@ func onTrunk(id, repository, seat, path string, manifest map[string]any) link.Bu } // keptAsked keeps a build request as the asker would: through a verb, or at the terminal. -func keptAsked(t *testing.T, inv *inventory.Inventory, id, repository string, atTerminal bool) { +func keptAsked(t *testing.T, inv *inventory.Inventory, id, repository, path string, atTerminal bool) { t.Helper() if err := inv.RecordBuildRequest(t.Context(), inventory.BuildRequest{ID: id, Repository: repository, Seat: "git", - For: "build", AtTerminal: atTerminal}); err != nil { + Path: path, For: "build", AtTerminal: atTerminal}); err != nil { t.Fatal(err) } } @@ -45,7 +51,7 @@ func theCatalogue(t *testing.T) *stores { onTrunk("build-sudo", "novox/mesh-catalog", "git", "modules/sudo", map[string]any{"module": "sudo", "version": "1"}), onTrunk("build-host", "novox/mesh-host", "git", "", map[string]any{"module": "mesh-host", "version": "1"}), } { - keptAsked(t, open.inventory, b.ID, b.Source.Repository, true) + keptAsked(t, open.inventory, b.ID, b.Source.Repository, b.Path, true) if _, _, err := takeIn(ctx, open.inventory, b); err != nil { t.Fatal(err) } @@ -65,7 +71,7 @@ func TestABuildFromAnAgentsRepositoryIsNotRegisteredAsAModuleTheMeshHolds(t *tes } { t.Run(c.name, func(t *testing.T) { id := "build-" + strings.ReplaceAll(c.repository, "/", "-") - keptAsked(t, open.inventory, id, c.repository, false) // through the build verb + keptAsked(t, open.inventory, id, c.repository, c.path, false) // through the build verb evil := onTrunk(id, c.repository, "git", c.path, map[string]any{"module": c.module, "version": "evil"}) _, _, err := takeIn(ctx, open.inventory, evil) if !errors.Is(err, errNotItsSource) { @@ -88,7 +94,7 @@ func TestABuildFromAnAgentsRepositoryIsNotRegisteredAsAModuleTheMeshHolds(t *tes func TestANewModuleFromARepositoryTheCatalogueDoesNotBuildFromIsNotRegistered(t *testing.T) { open := theCatalogue(t) ctx := t.Context() - keptAsked(t, open.inventory, "build-new", "agent/tools", false) + keptAsked(t, open.inventory, "build-new", "agent/tools", "", false) _, _, err := takeIn(ctx, open.inventory, onTrunk("build-new", "agent/tools", "git", "", map[string]any{"module": "agent-tools", "version": "1"})) if !errors.Is(err, errNotItsSource) { @@ -109,7 +115,7 @@ func TestANewModuleFromARepositoryTheCatalogueDoesNotBuildFromIsNotRegistered(t func TestAtTheTerminalAnotherRepositoryIsRegistered(t *testing.T) { open := theCatalogue(t) ctx := t.Context() - keptAsked(t, open.inventory, "build-moved", "novox/sudo", true) + keptAsked(t, open.inventory, "build-moved", "novox/sudo", "", true) if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-moved", "novox/sudo", "git", "", map[string]any{"module": "sudo", "version": "2"})); err != nil { t.Fatalf("a move the operator asked for at the terminal was refused: %v", err) @@ -117,7 +123,10 @@ func TestAtTheTerminalAnotherRepositoryIsRegistered(t *testing.T) { if src, _ := open.inventory.SourceOf(ctx, "sudo"); src.Repository != "novox/sudo" { t.Fatalf("sudo is built from %q", src.Repository) } - keptAsked(t, open.inventory, "build-external", "someone/app", true) + if err := open.inventory.RecordBuildRequest(ctx, inventory.BuildRequest{ID: "build-external", + Repository: "http://forge.internal:20000/someone/app.git", For: "build", AtTerminal: true}); err != nil { + t.Fatal(err) + } if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-external", "someone/app", "", "", map[string]any{"module": "app", "version": "1"})); err != nil { t.Fatalf("a new module the operator asked for at the terminal was refused: %v", err) @@ -195,7 +204,7 @@ func TestARollbackNeverPutsBackABuildFromAnotherRepository(t *testing.T) { fork := onTrunk("build-1791500000000000000", "agent/mesh-catalog", "git", "modules/sudo", map[string]any{"module": "sudo", "version": "evil"}) fork.Commit = "c0ffee0123456789" // the commit sudo was registered at - keptAsked(t, inv, fork.ID, "agent/mesh-catalog", false) + keptAsked(t, inv, fork.ID, "agent/mesh-catalog", "modules/sudo", false) if _, _, err := takeIn(ctx, inv, fork); !errors.Is(err, errNotItsSource) { t.Fatalf("the fork's build was taken in: %v", err) } @@ -220,3 +229,117 @@ func TestARollbackNeverPutsBackABuildFromAnotherRepository(t *testing.T) { t.Fatalf("a rollback puts back %q (found %v), want the registered build-sudo", previous.ID, found) } } + +// theForge is what the forge says in a test, by owner/name: a repository not named here is protected as a +// trunk must be, with an id of its own. +var theForge sync.Map + +func init() { + askTheForge = func(_ context.Context, owner, repo, _ string) (forgeFacts, error) { + if said, ok := theForge.Load(owner + "/" + repo); ok { + switch f := said.(type) { + case error: + return forgeFacts{}, f + case forgeFacts: + return f, nil + } + } + h := fnv.New32a() + _, _ = h.Write([]byte(owner + "/" + repo)) + return forgeFacts{ID: int64(h.Sum32()), Guarded: true}, nil + } +} + +// A module's trunk the forge does not protect — direct pushes, no required status — or a forge that cannot say, +// registers nothing through a verb: the trunk rule means nothing on a branch anyone pushes to. +func TestATrunkTheForgeDoesNotProtectRegistersNothing(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + theForge.Store("novox/unguarded", forgeFacts{ID: 7, Why: "the rule main lets a person push to main directly"}) + t.Cleanup(func() { theForge.Delete("novox/unguarded") }) + first := onTrunk("build-unguarded-1", "novox/unguarded", "git", "", map[string]any{"module": "unguarded", "version": "1"}) + keptAsked(t, open.inventory, first.ID, "novox/unguarded", "", true) + if _, _, err := takeIn(ctx, open.inventory, first); err != nil { + t.Fatalf("at the terminal: %v", err) + } + again := onTrunk("build-unguarded-2", "novox/unguarded", "git", "", map[string]any{"module": "unguarded", "version": "2"}) + if _, _, err := takeIn(ctx, open.inventory, again); !errors.Is(err, errNotItsSource) || + !strings.Contains(err.Error(), "push to main directly") { + t.Fatalf("a rebuild from an unprotected trunk was taken in: %v", err) + } + theForge.Store("novox/unguarded", errors.New("nothing serves gitea.gitea_api")) + if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-unguarded-3", "novox/unguarded", "git", "", + map[string]any{"module": "unguarded", "version": "3"})); !errors.Is(err, errNotItsSource) { + t.Fatalf("a forge that could not say was read as a protected trunk: %v", err) + } + if shelf, _ := open.inventory.Catalogue(ctx); shelf["unguarded"].Version != "1" { + t.Fatalf("unguarded is %q", shelf["unguarded"].Version) + } +} + +// A repository deleted and made again under the module's repository's name is another repository: the forge's +// id, recorded at registration, tells them apart. +func TestARepositoryMadeAgainUnderItsNameIsNotTheModulesSource(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + theForge.Store("novox/remade", forgeFacts{ID: 100, Guarded: true}) + t.Cleanup(func() { theForge.Delete("novox/remade") }) + first := onTrunk("build-remade-1", "novox/remade", "git", "", map[string]any{"module": "remade", "version": "1"}) + keptAsked(t, open.inventory, first.ID, "novox/remade", "", true) + if _, _, err := takeIn(ctx, open.inventory, first); err != nil { + t.Fatal(err) + } + if id, _ := open.inventory.SourceIdentity(ctx, "remade"); id != 100 { + t.Fatalf("the forge's id was not recorded: %d", id) + } + theForge.Store("novox/remade", forgeFacts{ID: 101, Guarded: true}) // deleted, and made again by an agent + if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-remade-2", "novox/remade", "git", "", + map[string]any{"module": "remade", "version": "evil"})); !errors.Is(err, errNotItsSource) || + !strings.Contains(err.Error(), "made again") { + t.Fatalf("a repository made again under the name was taken in: %v", err) + } + // And a new module from it, beside the one registered from the first, the same. + if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-remade-3", "novox/remade", "git", "modules/other", + map[string]any{"module": "other", "version": "1"})); !errors.Is(err, errNotItsSource) { + t.Fatalf("a new module from a repository made again was taken in: %v", err) + } +} + +// The terminal's mark is the operator's for the repository and path they asked: an outcome of another, under that +// build's id, is not theirs. +func TestATerminalRequestCoversOnlyWhatItAsked(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + if err := open.inventory.RecordBuildRequest(ctx, inventory.BuildRequest{ID: "build-asked", Repository: "novox/app", + Seat: "git", Path: "modules/app", For: "build", AtTerminal: true}); err != nil { + t.Fatal(err) + } + other := onTrunk("build-asked", "agent/sudo", "git", "modules/app", map[string]any{"module": "sudo", "version": "evil"}) + if _, _, err := takeIn(ctx, open.inventory, other); !errors.Is(err, errNotItsSource) { + t.Fatalf("an outcome of another repository under a terminal request's id was taken in: %v", err) + } + elsewhere := onTrunk("build-asked", "novox/app", "git", "modules/sudo", map[string]any{"module": "sudo", "version": "evil"}) + if _, _, err := takeIn(ctx, open.inventory, elsewhere); !errors.Is(err, errNotItsSource) { + t.Fatalf("an outcome of another path under a terminal request's id was taken in: %v", err) + } +} + +// The serving controller, and everything it starts, is never the terminal: a build asked in it reads as asked +// through the mesh even when no verb and no caller is named. +func TestTheServingControllerIsNeverTheTerminal(t *testing.T) { + t.Setenv(verbVar, "") + t.Setenv(link.CallerVar, "") + t.Setenv(servedVar, "") + if !startedAtTheTerminal() { + t.Fatal("a process started by hand is not the terminal") + } + markServed() + if startedAtTheTerminal() { + t.Fatal("the serving controller reads as the terminal") + } + child := exec.Command(os.Args[0], "-test.run=^$") + child.Env = os.Environ() + if !slices.Contains(child.Env, servedVar+"=1") { + t.Fatal("what the serving controller starts does not carry its mark") + } +} diff --git a/cmd/mesh-controller/push.go b/cmd/mesh-controller/push.go index a4f54d63..7495ea4e 100644 --- a/cmd/mesh-controller/push.go +++ b/cmd/mesh-controller/push.go @@ -65,6 +65,8 @@ func connectLink(ctx context.Context, inv *inventory.Inventory, enroller link.En } func serve(ctx context.Context) (err error) { + // Nothing this process does, or starts, is the operator at the terminal (novox/hq ADR 0266). + markServed() // The one process whose log is read over time, so the one that says each change to a node's // unmet seat dependencies once (novox/hq ADR 0207). logUnheldChanges = true diff --git a/internal/inventory/catalogue.go b/internal/inventory/catalogue.go index 3112962c..71350a92 100644 --- a/internal/inventory/catalogue.go +++ b/internal/inventory/catalogue.go @@ -169,6 +169,25 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr return nil } +// SourceIdentity is the forge's id of the repository a module is registered from, 0 when none is recorded. +func (i *Inventory) SourceIdentity(ctx context.Context, module string) (int64, error) { + var id *int64 + err := i.store.Pool().QueryRow(ctx, `select source_repo_id from module where name = $1`, module).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return 0, fmt.Errorf("%w: %s", ErrNoSuchModule, module) + } + if err != nil || id == nil { + return 0, err + } + return *id, nil +} + +// SetSourceIdentity records the forge's id of the repository a module is registered from; 0 records none. +func (i *Inventory) SetSourceIdentity(ctx context.Context, module string, id int64) error { + _, err := i.store.Pool().Exec(ctx, `update module set source_repo_id = nullif($2::bigint, 0) where name = $1`, module, id) + return err +} + // hasModule is whether the catalogue holds a module of that name. func (i *Inventory) hasModule(ctx context.Context, name string) (bool, error) { var one int diff --git a/internal/inventory/migrations/0084-a-build-asked-at-the-terminal-says-so.sql b/internal/inventory/migrations/0084-a-build-asked-at-the-terminal-says-so.sql index 78a8d98c..650a8cf8 100644 --- a/internal/inventory/migrations/0084-a-build-asked-at-the-terminal-says-so.sql +++ b/internal/inventory/migrations/0084-a-build-asked-at-the-terminal-says-so.sql @@ -9,3 +9,10 @@ -- request kept by a `build` or `replay --register` run at the terminal, never through a verb (whoever may -- call a verb includes agents). False for every request kept before this column existed. alter table build_request add column at_terminal boolean not null default false; + +-- And which repository a module is registered from, by the forge's own id for it (novox/hq ADR 0266): a name +-- is not an identity. A repository deleted and made again under the same name is another repository, with +-- none of the protection the first had until someone sets it; its outcome must not register as the module's. +-- Recorded when a build of it is registered from the mesh's own forge; null until then, and for a source the +-- forge does not hold. +alter table module add column source_repo_id bigint; diff --git a/internal/inventory/pending.go b/internal/inventory/pending.go index 9cb5dd38..0e761e31 100644 --- a/internal/inventory/pending.go +++ b/internal/inventory/pending.go @@ -88,16 +88,17 @@ func (i *Inventory) RecordBuildRequest(ctx context.Context, a BuildRequest) erro return err } -// AskedAtTheTerminal says whether the build of this id was kept as asked at the controller's terminal (novox/hq -// ADR 0266). False for a build no request was kept for — a check, a dry run, an outcome nobody here asked for — -// and for every request asked through a verb. -func (i *Inventory) AskedAtTheTerminal(ctx context.Context, id string) (bool, error) { - var at bool - err := i.store.Pool().QueryRow(ctx, `select at_terminal from build_request where id = $1`, id).Scan(&at) +// BuildRequestByID is the build request kept under this id, and whether one was kept. +func (i *Inventory) BuildRequestByID(ctx context.Context, id string) (BuildRequest, bool, error) { + var a BuildRequest + err := i.store.Pool().QueryRow(ctx, + `select id, repository, seat, source_path, ref, commit_hash, asked_for, at_terminal, asked_at + from build_request where id = $1`, id).Scan(&a.ID, &a.Repository, &a.Seat, &a.Path, &a.Ref, &a.Commit, + &a.For, &a.AtTerminal, &a.At) if errors.Is(err, pgx.ErrNoRows) { - return false, nil + return BuildRequest{}, false, nil } - return at, err + return a, err == nil, err } // MarkNotAsked says a kept build request was never handed over: the words are kept, unless its outcome was