Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88e84a4dd7 | ||
|
|
abd3078491 | ||
|
|
0e5aed1253 |
@@ -306,9 +306,6 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
|
|||||||
for _, r := range built.Read {
|
for _, r := range built.Read {
|
||||||
result.Read = append(result.Read, link.ReadRepository{Repository: r.Repository, Ref: r.Ref})
|
result.Read = append(result.Read, link.ReadRepository{Repository: r.Repository, Ref: r.Ref})
|
||||||
}
|
}
|
||||||
for _, s := range built.Sources {
|
|
||||||
result.Sources = append(result.Sources, link.BuildSource{Repository: s.Repository, Ref: s.Ref, Paths: s.Paths})
|
|
||||||
}
|
|
||||||
say("built", built.Manifest.Module+" from "+short(built.Commit))
|
say("built", built.Manifest.Module+" from "+short(built.Commit))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,10 +166,6 @@ func buildFrom(result link.BuildResult) inventory.Build {
|
|||||||
for _, r := range result.Read {
|
for _, r := range result.Read {
|
||||||
kept.Read = append(kept.Read, inventory.ReadRepository{Repository: r.Repository, Ref: r.Ref})
|
kept.Read = append(kept.Read, inventory.ReadRepository{Repository: r.Repository, Ref: r.Ref})
|
||||||
}
|
}
|
||||||
// What it was made from, as files (novox/hq ADR 0267): the planner maps the next merge onto it.
|
|
||||||
for _, s := range result.Sources {
|
|
||||||
kept.Sources = append(kept.Sources, inventory.BuildSource{Repository: s.Repository, Ref: s.Ref, Paths: s.Paths})
|
|
||||||
}
|
|
||||||
var announced []inventory.Artifact
|
var announced []inventory.Artifact
|
||||||
for _, made := range result.Made {
|
for _, made := range result.Made {
|
||||||
announced = append(announced, inventory.Artifact{
|
announced = append(announced, inventory.Artifact{
|
||||||
|
|||||||
@@ -32,9 +32,6 @@ import (
|
|||||||
// provision it wants that a manifest given here offers. An overflow is refused in the pull request
|
// provision it wants that a manifest given here offers. An overflow is refused in the pull request
|
||||||
// that introduces it — a new requirement, a lowered bound, a longer slug — instead of on the
|
// that introduces it — a new requirement, a lowered bound, a longer slug — instead of on the
|
||||||
// provider's machine when a real machine's name first meets the module's.
|
// provider's machine when a real machine's name first meets the module's.
|
||||||
// SomeManifestsVar, set by the merge gate, says the manifests given are only some of their repository's.
|
|
||||||
const SomeManifestsVar = "MESH_MODULE_CHECK_SOME"
|
|
||||||
|
|
||||||
func moduleCheck(paths []string, out io.Writer) error {
|
func moduleCheck(paths []string, out io.Writer) error {
|
||||||
return moduleCheckFor(paths, catalogue.DefaultLongestMachine, out)
|
return moduleCheckFor(paths, catalogue.DefaultLongestMachine, out)
|
||||||
}
|
}
|
||||||
@@ -88,24 +85,12 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
|
|||||||
// Between the manifests: a seat declared twice, a use of a seat nothing declares, a claim on
|
// Between the manifests: a seat declared twice, a use of a seat nothing declares, a claim on
|
||||||
// a seat that does not exist. Run only over what parsed, because a problem inside one manifest
|
// a seat that does not exist. Run only over what parsed, because a problem inside one manifest
|
||||||
// has already been said and would be said again here in a worse form.
|
// has already been said and would be said again here in a worse form.
|
||||||
//
|
|
||||||
// **Over some of a repository's manifests, a seat none of them declares is a note** (novox/hq issue 364), as
|
|
||||||
// this command's own word says above: the merge gate passes only the manifests a change touches, and says so
|
|
||||||
// with SomeManifestsVar, so a module that uses or claims a seat another module declares (the operator
|
|
||||||
// channel's, a channel bench) was refused there for a manifest it was not given. Given every manifest — the
|
|
||||||
// catalogue's own check, and registration — it stays a refusal.
|
|
||||||
some := os.Getenv(SomeManifestsVar) != ""
|
|
||||||
problems := catalogue.CatalogueProblems(shelf)
|
problems := catalogue.CatalogueProblems(shelf)
|
||||||
sort.Strings(problems)
|
sort.Strings(problems)
|
||||||
for _, p := range problems {
|
for _, p := range problems {
|
||||||
if some && catalogue.IsUndeclaredSeat(p) {
|
|
||||||
fmt.Fprintf(out, "note: %s among the manifests given; registration judges it against the whole catalogue, "+
|
|
||||||
"and passing the declaring module's manifest too judges it here\n", p)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fmt.Fprintln(out, p)
|
fmt.Fprintln(out, p)
|
||||||
failed++
|
|
||||||
}
|
}
|
||||||
|
failed += len(problems)
|
||||||
|
|
||||||
// Between the manifests too: an identity against the bounds of the provisions it wants, which
|
// Between the manifests too: an identity against the bounds of the provisions it wants, which
|
||||||
// only the provider's manifest states.
|
// only the provider's manifest states.
|
||||||
|
|||||||
@@ -82,9 +82,7 @@ func checkHereCommand(ctx context.Context, args []string) error {
|
|||||||
if _, err := git("fetch", "--quiet", "origin", *base); err != nil {
|
if _, err := git("fetch", "--quiet", "origin", *base); err != nil {
|
||||||
return fmt.Errorf("cannot fetch %s to say what the change touches: %w", *base, err)
|
return fmt.Errorf("cannot fetch %s to say what the change touches: %w", *base, err)
|
||||||
}
|
}
|
||||||
// Without rename detection, so a file moved out of a build source is said under its old name as well:
|
changedText, err := git("diff", "--name-only", "origin/"+*base+"...HEAD")
|
||||||
// its going is a change to the build that held it (novox/hq ADR 0267).
|
|
||||||
changedText, err := git("diff", "--name-only", "--no-renames", "origin/"+*base+"...HEAD")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,23 +103,3 @@ func TestTheControllersManifestServesEveryVerbOfItsSeat(t *testing.T) {
|
|||||||
t.Fatalf("the controller's own module.json fails module check: %v\n%s", err, out.String())
|
t.Fatalf("the controller's own module.json fails module check: %v\n%s", err, out.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A module that uses a seat another module declares (novox/hq issue 364): refused over the whole catalogue when the
|
|
||||||
// declarer is missing, a note when the gate says it gives only the manifests a change touches.
|
|
||||||
func TestASeatAnotherModuleDeclaresIsANoteOverSomeManifests(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
user := filepath.Join(dir, "user.json")
|
|
||||||
os.WriteFile(user, []byte(`{"module":"asker","version":"1","uses":["operator-channel"]}`), 0o600)
|
|
||||||
var out bytes.Buffer
|
|
||||||
if err := moduleCheck([]string{user}, &out); err == nil {
|
|
||||||
t.Fatalf("a use of a seat nothing given declares passed the whole-catalogue check:\n%s", out.String())
|
|
||||||
}
|
|
||||||
t.Setenv(SomeManifestsVar, "1")
|
|
||||||
out.Reset()
|
|
||||||
if err := moduleCheck([]string{user}, &out); err != nil {
|
|
||||||
t.Fatalf("over some manifests the use was refused:\n%s", out.String())
|
|
||||||
}
|
|
||||||
if !strings.Contains(out.String(), "note: asker uses the seat \"operator-channel\"") {
|
|
||||||
t.Fatalf("the note was not said:\n%s", out.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ func (f following) PullUpdated(ctx context.Context, p link.PullUpdated) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
read, err := readForPlanning(ctx, inv)
|
read, err := inv.ReadRepositories(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -604,7 +604,7 @@ func theGraph(ctx context.Context, inv *inventory.Inventory) ([]inventory.Entry,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
read, err := readForPlanning(ctx, inv)
|
read, err := inv.ReadRepositories(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return snapshot.Facts{}, err
|
return snapshot.Facts{}, err
|
||||||
}
|
}
|
||||||
read, err := readForPlanning(ctx, inv)
|
read, err := inv.ReadRepositories(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return snapshot.Facts{}, err
|
return snapshot.Facts{}, err
|
||||||
}
|
}
|
||||||
@@ -411,16 +411,7 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot
|
|||||||
Commit: e.Source.BuiltFrom, Provided: e.Provided, RollOut: current[e.Manifest.Module].RollOut,
|
Commit: e.Source.BuiltFrom, Provided: e.Provided, RollOut: current[e.Manifest.Module].RollOut,
|
||||||
Manifest: raw}
|
Manifest: raw}
|
||||||
for _, r := range read[e.Manifest.Module] {
|
for _, r := range read[e.Manifest.Module] {
|
||||||
// The module's own build source is said apart: a gate that predates it would read an own
|
|
||||||
// entry among Reads as a context of its own repository.
|
|
||||||
if r.Own {
|
|
||||||
mod.Sources = append(mod.Sources, snapshot.BuildSource{Own: true, Paths: r.Paths})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
mod.Reads = append(mod.Reads, snapshot.RepositoryName(r.Repository))
|
mod.Reads = append(mod.Reads, snapshot.RepositoryName(r.Repository))
|
||||||
if len(r.Paths) > 0 {
|
|
||||||
mod.Sources = append(mod.Sources, snapshot.BuildSource{Repository: snapshot.RepositoryName(r.Repository), Paths: r.Paths})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
f.Modules = append(f.Modules, mod)
|
f.Modules = append(f.Modules, mod)
|
||||||
if e.Provided || e.Source.Repository == "" {
|
if e.Provided || e.Source.Repository == "" {
|
||||||
|
|||||||
@@ -251,6 +251,9 @@ func handActCommand(ctx context.Context, args []string) error {
|
|||||||
if len(args) > 0 && args[0] == "drill" {
|
if len(args) > 0 && args[0] == "drill" {
|
||||||
return handActDrill(ctx, args[1:])
|
return handActDrill(ctx, args[1:])
|
||||||
}
|
}
|
||||||
|
if len(args) > 0 && args[0] == "warrant" {
|
||||||
|
return handActWarrantCommand(ctx, args[1:])
|
||||||
|
}
|
||||||
if len(args) > 0 && args[0] != "list" && !strings.HasPrefix(args[0], "-") {
|
if len(args) > 0 && args[0] != "list" && !strings.HasPrefix(args[0], "-") {
|
||||||
return errors.New("hand-act record <what> --why <text> --cause <word> | hand-act drill <what> --why <text> " +
|
return errors.New("hand-act record <what> --why <text> --cause <word> | hand-act drill <what> --why <text> " +
|
||||||
"| hand-acts [--days N] [--json]")
|
"| hand-acts [--days N] [--json]")
|
||||||
|
|||||||
@@ -1204,18 +1204,7 @@ func graphOfFacts(f snapshot.Facts) ([]inventory.Entry, map[string][]inventory.R
|
|||||||
entries = append(entries, inventory.Entry{Manifest: manifest, Provided: mod.Provided,
|
entries = append(entries, inventory.Entry{Manifest: manifest, Provided: mod.Provided,
|
||||||
Source: inventory.Source{Repository: mod.Repository, Path: mod.Path, BuiltFrom: mod.Commit}})
|
Source: inventory.Source{Repository: mod.Repository, Path: mod.Path, BuiltFrom: mod.Commit}})
|
||||||
for _, r := range mod.Reads {
|
for _, r := range mod.Reads {
|
||||||
entry := inventory.ReadRepository{Repository: r}
|
read[mod.Name] = append(read[mod.Name], inventory.ReadRepository{Repository: r})
|
||||||
for _, s := range mod.Sources {
|
|
||||||
if !s.Own && s.Repository == r && len(s.Paths) > 0 {
|
|
||||||
entry.Paths = s.Paths
|
|
||||||
}
|
|
||||||
}
|
|
||||||
read[mod.Name] = append(read[mod.Name], entry)
|
|
||||||
}
|
|
||||||
for _, s := range mod.Sources {
|
|
||||||
if s.Own && len(s.Paths) > 0 {
|
|
||||||
read[mod.Name] = append(read[mod.Name], inventory.ReadRepository{Own: true, Paths: s.Paths})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var edges []inventory.Edge
|
var edges []inventory.Edge
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ func catchingUpOnMerges(ctx context.Context, open *stores, announced merges) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
read, err := readForPlanning(ctx, open.inventory)
|
read, err := open.inventory.ReadRepositories(ctx)
|
||||||
return entries, read, err
|
return entries, read, err
|
||||||
}
|
}
|
||||||
failing := ""
|
failing := ""
|
||||||
@@ -102,11 +102,6 @@ func catchUpOnMerges(ctx context.Context, now time.Time, announced merges,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, a := range all {
|
for _, a := range all {
|
||||||
// A merge the forge said no time of is dated by its announcement, so a packaging module's look can
|
|
||||||
// make it history once acted on, and the catch-up does not act on it again every pass (ADR 0267).
|
|
||||||
if a.SourceMoved.MergedAt == "" && !a.At.IsZero() {
|
|
||||||
a.SourceMoved.MergedAt = a.At.UTC().Format(time.RFC3339)
|
|
||||||
}
|
|
||||||
if now.Sub(a.At) < mergeGrace {
|
if now.Sub(a.At) < mergeGrace {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ func TestAMergeRebuildsTheModulesItChanged(t *testing.T) {
|
|||||||
{"a file directly among the modules", merge([]string{"modules/README.md"}, false), ""},
|
{"a file directly among the modules", merge([]string{"modules/README.md"}, false), ""},
|
||||||
{"a root file beside a module's", merge([]string{"merge-check.sh", "modules/keycloak/x.ts"}, false), "keycloak"},
|
{"a root file beside a module's", merge([]string{"merge-check.sh", "modules/keycloak/x.ts"}, false), "keycloak"},
|
||||||
} {
|
} {
|
||||||
if got := named(whatTheMergeTouched(candidates, known, c.m, nil)); got != c.want {
|
if got := named(whatTheMergeTouched(candidates, known, c.m)); got != c.want {
|
||||||
t.Errorf("%s: rebuilt %q, wanted %q", c.what, got, c.want)
|
t.Errorf("%s: rebuilt %q, wanted %q", c.what, got, c.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -285,7 +285,7 @@ func TestAChangeInsideAModuleIsThatModulesHeldOrNot(t *testing.T) {
|
|||||||
{"an old announcer saying nothing", merge(showcase, nil, false), ""},
|
{"an old announcer saying nothing", merge(showcase, nil, false), ""},
|
||||||
{"a manifest the merge removed, said or not", merge([]string{"modules/gone/module.json", "modules/gone/x.ts"}, nil, true), ""},
|
{"a manifest the merge removed, said or not", merge([]string{"modules/gone/module.json", "modules/gone/x.ts"}, nil, true), ""},
|
||||||
} {
|
} {
|
||||||
if got := named(whatTheMergeTouched(candidates, known, c.m, nil)); got != c.want {
|
if got := named(whatTheMergeTouched(candidates, known, c.m)); got != c.want {
|
||||||
t.Errorf("%s: rebuilt %q, wanted %q", c.what, got, c.want)
|
t.Errorf("%s: rebuilt %q, wanted %q", c.what, got, c.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,16 +15,16 @@ import (
|
|||||||
// inventory.Dependencies (dependenciesOf over the records), and the merge planned by reachOfMerge — the
|
// inventory.Dependencies (dependenciesOf over the records), and the merge planned by reachOfMerge — the
|
||||||
// path a real merge takes, short of the bus.
|
// path a real merge takes, short of the bus.
|
||||||
//
|
//
|
||||||
// **A shared repository moves only what a change's files are in the build source of** (novox/hq ADR 0267,
|
// **The repository rows are CURRENT BEHAVIOUR, documented — not the rule the operator states**
|
||||||
// issues 338 and 363): each build records the build source it said, and a merge is mapped onto those of the
|
// (novox/hq issue 338, and the decision pending on it): a build that read a repository gives its module a
|
||||||
// newest builds. A build that said none (P below, as every build before ADR 0267) is read as before: P moves
|
// packages edge to every module built from that repository, and mergeCandidates moves it on any merge to
|
||||||
// on any merge to the repository it packages, though through no edge. The rows tagged 338 held the opposite
|
// that repository, whatever the files. So a change to C alone, or to a README, moves the module that
|
||||||
// until ADR 0267 was built.
|
// packages C's repository. ADR 0238 §3 records exactly that today ("a repository a recipe names"); the
|
||||||
func TestASharedRepositoryIsPlannedFromTheRecordedBuildSources(t *testing.T) {
|
// expectations marked 338 change with that decision.
|
||||||
|
func TestASharedRepositoryIsPlannedFromTheRecordsAsItIsToday(t *testing.T) {
|
||||||
inv := inventory.ForTest(t)
|
inv := inventory.ForTest(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
asked := time.Now().Add(-time.Hour)
|
asked := time.Now().Add(-time.Hour)
|
||||||
sourcesOf := map[string][]inventory.BuildSource{}
|
|
||||||
register := func(m catalogue.Manifest, repository, path string, against []string, read []inventory.ReadRepository) {
|
register := func(m catalogue.Manifest, repository, path string, against []string, read []inventory.ReadRepository) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err := inv.RegisterModule(ctx, m, inventory.Source{Repository: repository, Seat: "git", Path: path,
|
if err := inv.RegisterModule(ctx, m, inventory.Source{Repository: repository, Seat: "git", Path: path,
|
||||||
@@ -32,8 +32,7 @@ func TestASharedRepositoryIsPlannedFromTheRecordedBuildSources(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-" + m.Module, Repository: repository, Ref: "main",
|
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-" + m.Module, Repository: repository, Ref: "main",
|
||||||
Module: m.Module, Commit: "old", On: "builder", Path: path, Against: against, Read: read, Asked: asked,
|
Module: m.Module, Commit: "old", On: "builder", Path: path, Against: against, Read: read, Asked: asked}); err != nil {
|
||||||
Sources: sourcesOf[m.Module]}); err != nil {
|
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,16 +40,7 @@ func TestASharedRepositoryIsPlannedFromTheRecordedBuildSources(t *testing.T) {
|
|||||||
agent := catalogue.Manifest{Module: "build-agent", Version: "1",
|
agent := catalogue.Manifest{Module: "build-agent", Version: "1",
|
||||||
Claims: []catalogue.Claim{{Name: "node-build-agent", Scope: catalogue.ScopeNode}}}
|
Claims: []catalogue.Claim{{Name: "node-build-agent", Scope: catalogue.ScopeNode}}}
|
||||||
|
|
||||||
// The shape of issue 338, each build saying its build source (ADR 0267).
|
// The shape of issue 338.
|
||||||
gomod := []string{"go.mod", "go.sum"}
|
|
||||||
sourcesOf["mesh-controller"] = []inventory.BuildSource{{Paths: append([]string{"module.json", "cmd/mesh-controller/",
|
|
||||||
"internal/conditions/", "internal/broker/"}, gomod...)}}
|
|
||||||
sourcesOf["build-agent"] = []inventory.BuildSource{
|
|
||||||
{Paths: []string{"modules/build-agent/Dockerfile", "modules/build-agent/module.json"}},
|
|
||||||
{Repository: "novox/mesh-controller", Ref: "main", Paths: append([]string{"cmd/mesh-builder/", "internal/broker/"}, gomod...)}}
|
|
||||||
sourcesOf["route-proxy"] = []inventory.BuildSource{
|
|
||||||
{Paths: []string{"modules/route-proxy/Dockerfile", "modules/route-proxy/module.json"}},
|
|
||||||
{Repository: "novox/mesh-controller", Ref: "main", Paths: append([]string{"examples/route-proxy/", "internal/broker/"}, gomod...)}}
|
|
||||||
register(catalogue.Manifest{Module: "mesh-controller", Version: "1"}, "novox/mesh-controller", "", nil, nil)
|
register(catalogue.Manifest{Module: "mesh-controller", Version: "1"}, "novox/mesh-controller", "", nil, nil)
|
||||||
register(agent, "novox/mesh-catalog", "modules/build-agent", nil, controllerRead)
|
register(agent, "novox/mesh-catalog", "modules/build-agent", nil, controllerRead)
|
||||||
register(catalogue.Manifest{Module: "route-proxy", Version: "1"}, "novox/mesh-catalog", "modules/route-proxy", nil, controllerRead)
|
register(catalogue.Manifest{Module: "route-proxy", Version: "1"}, "novox/mesh-catalog", "modules/route-proxy", nil, controllerRead)
|
||||||
@@ -91,15 +81,13 @@ func TestASharedRepositoryIsPlannedFromTheRecordedBuildSources(t *testing.T) {
|
|||||||
if !reflect.DeepEqual(shared, sharedRepositoryEdges) {
|
if !reflect.DeepEqual(shared, sharedRepositoryEdges) {
|
||||||
t.Errorf("derived %v\nthe hand-written rows use %v", shared, sharedRepositoryEdges)
|
t.Errorf("derived %v\nthe hand-written rows use %v", shared, sharedRepositoryEdges)
|
||||||
}
|
}
|
||||||
// Each kind derived from its record: built against (stands-on), build.on (declared); a read draws none.
|
// Each kind derived from its record: built against (stands-on), build.on (declared), read (packages).
|
||||||
for _, e := range edges {
|
|
||||||
if e.Kind == inventory.EdgePackages {
|
|
||||||
t.Errorf("a packages edge was drawn (ADR 0267 rule 4): %v", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, want := range []inventory.Edge{
|
for _, want := range []inventory.Edge{
|
||||||
dep("d", inventory.EdgeStandsOn, "a"),
|
dep("d", inventory.EdgeStandsOn, "a"),
|
||||||
dep("e", inventory.EdgeDeclared, "b"),
|
dep("e", inventory.EdgeDeclared, "b"),
|
||||||
|
dep("p", inventory.EdgePackages, "a"),
|
||||||
|
dep("p", inventory.EdgePackages, "b"),
|
||||||
|
dep("p", inventory.EdgePackages, "c"),
|
||||||
dep("d", inventory.EdgeBuiltBy, "build-agent"),
|
dep("d", inventory.EdgeBuiltBy, "build-agent"),
|
||||||
} {
|
} {
|
||||||
found := false
|
found := false
|
||||||
@@ -117,23 +105,15 @@ func TestASharedRepositoryIsPlannedFromTheRecordedBuildSources(t *testing.T) {
|
|||||||
want string
|
want string
|
||||||
issue338 bool
|
issue338 bool
|
||||||
}{
|
}{
|
||||||
{"A and B changed, C untouched: D after A, E after B; P, which said no build source, reads all", "one",
|
{"A and B changed, C untouched: D after A, E after B; P packages their repository", "one",
|
||||||
[]string{"modules/a/x.go", "modules/b/x.go"}, "a,b,p | d,e", false},
|
[]string{"modules/a/x.go", "modules/b/x.go"}, "a,b,p | d,e", false},
|
||||||
{"C alone: C, and P, read whole as before; P after nothing", "one",
|
{"C alone: C, and P, which packages C's repository", "one",
|
||||||
[]string{"modules/c/x.go"}, "c,p", false},
|
[]string{"modules/c/x.go"}, "c,p", true},
|
||||||
{"a README of the repository P packages: P, read whole as before", "one",
|
{"a README of the repository P packages: P moves, nothing built from it does", "one",
|
||||||
[]string{"README.md"}, "p", false},
|
[]string{"README.md"}, "p", true},
|
||||||
{"the dependent's repository: D alone", "two", []string{"d/main.go"}, "d", false},
|
{"the dependent's repository: D alone", "two", []string{"d/main.go"}, "d", false},
|
||||||
{"a README of the controller's repository: no module", "mesh-controller",
|
{"a README of the controller's repository: all three, three tiers", "mesh-controller",
|
||||||
[]string{"README.md"}, "", true},
|
[]string{"README.md"}, "mesh-controller | build-agent | route-proxy", true},
|
||||||
{"the controller's own command: the controller alone", "mesh-controller",
|
|
||||||
[]string{"cmd/mesh-controller/main.go"}, "mesh-controller", true},
|
|
||||||
{"a package only the controller builds from: the controller alone", "mesh-controller",
|
|
||||||
[]string{"internal/conditions/condition.go"}, "mesh-controller", true},
|
|
||||||
{"the route proxy's program: the route proxy alone", "mesh-controller",
|
|
||||||
[]string{"examples/route-proxy/main.go"}, "route-proxy", true},
|
|
||||||
{"a package all three build from: all three", "mesh-controller",
|
|
||||||
[]string{"internal/broker/broker.go"}, "mesh-controller | build-agent | route-proxy", false},
|
|
||||||
{"the route proxy's directory in the catalogue: it alone", "mesh-catalog",
|
{"the route proxy's directory in the catalogue: it alone", "mesh-catalog",
|
||||||
[]string{"modules/route-proxy/module.json"}, "route-proxy", false},
|
[]string{"modules/route-proxy/module.json"}, "route-proxy", false},
|
||||||
{"the build agent's directory: it alone, nothing it builds", "mesh-catalog",
|
{"the build agent's directory: it alone, nothing it builds", "mesh-catalog",
|
||||||
@@ -143,7 +123,7 @@ func TestASharedRepositoryIsPlannedFromTheRecordedBuildSources(t *testing.T) {
|
|||||||
if got != c.want {
|
if got != c.want {
|
||||||
tag := ""
|
tag := ""
|
||||||
if c.issue338 {
|
if c.issue338 {
|
||||||
tag = " (issue 338, flipped by ADR 0267)"
|
tag = " (current behaviour, issue 338)"
|
||||||
}
|
}
|
||||||
t.Errorf("%s: planned %q, wanted %q%s", c.what, got, c.want, tag)
|
t.Errorf("%s: planned %q, wanted %q%s", c.what, got, c.want, tag)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
snapshot "github.com/novox/mesh-controller/internal/facts"
|
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
)
|
)
|
||||||
@@ -24,7 +21,7 @@ import (
|
|||||||
// kind widens the plan orders the tiers
|
// kind widens the plan orders the tiers
|
||||||
// stands-on yes yes, after its base is built
|
// stands-on yes yes, after its base is built
|
||||||
// declared yes yes, after its base is built
|
// declared yes yes, after its base is built
|
||||||
// packages no no — retired by novox/hq ADR 0267; one recorded before is read and ignored
|
// packages yes no, the same tier (a code dependency)
|
||||||
// built-by no yes, after the build machine — except for what the build machine stands
|
// built-by no yes, after the build machine — except for what the build machine stands
|
||||||
// on, and for the controller whose worker it binds
|
// on, and for the controller whose worker it binds
|
||||||
// worker-of no yes, the build seat's holder after the controller (hq issue 206)
|
// worker-of no yes, the build seat's holder after the controller (hq issue 206)
|
||||||
@@ -126,9 +123,9 @@ func TestAPlanIsWhatTheChangeTouchedAndWhatIsBuiltOnIt(t *testing.T) {
|
|||||||
{what: "transitive: F on D on A, A changed",
|
{what: "transitive: F on D on A, A changed",
|
||||||
edges: []inventory.Edge{dep("f", standsOn, "d"), dep("d", standsOn, "a")},
|
edges: []inventory.Edge{dep("f", standsOn, "d"), dep("d", standsOn, "a")},
|
||||||
repo: "one", paths: []string{"modules/a/x"}, want: "a | d | f"},
|
repo: "one", paths: []string{"modules/a/x"}, want: "a | d | f"},
|
||||||
{what: "transitive across kinds stops at a packages edge: F declared on D, D packages A (ADR 0267)",
|
{what: "transitive across kinds: F declared on D, D packages A",
|
||||||
edges: []inventory.Edge{dep("f", declared, "d"), dep("d", packages, "a")},
|
edges: []inventory.Edge{dep("f", declared, "d"), dep("d", packages, "a")},
|
||||||
repo: "one", paths: []string{"modules/a/x"}, want: "a"},
|
repo: "one", paths: []string{"modules/a/x"}, want: "a,d | f"},
|
||||||
|
|
||||||
// Each kind alone: X depends on A, A changed (widening), then both changed (ordering).
|
// Each kind alone: X depends on A, A changed (widening), then both changed (ordering).
|
||||||
{what: "stands-on (built against A's artifact) widens", edges: []inventory.Edge{dep("x", standsOn, "a")},
|
{what: "stands-on (built against A's artifact) widens", edges: []inventory.Edge{dep("x", standsOn, "a")},
|
||||||
@@ -139,8 +136,8 @@ func TestAPlanIsWhatTheChangeTouchedAndWhatIsBuiltOnIt(t *testing.T) {
|
|||||||
repo: "one", paths: []string{"modules/a/x"}, want: "a | x"},
|
repo: "one", paths: []string{"modules/a/x"}, want: "a | x"},
|
||||||
{what: "declared orders", edges: []inventory.Edge{dep("x", declared, "a")},
|
{what: "declared orders", edges: []inventory.Edge{dep("x", declared, "a")},
|
||||||
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
|
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
|
||||||
{what: "packages, recorded before ADR 0267, widens nothing", edges: []inventory.Edge{dep("x", packages, "a")},
|
{what: "packages widens, into the same tier", edges: []inventory.Edge{dep("x", packages, "a")},
|
||||||
repo: "one", paths: []string{"modules/a/x"}, want: "a"},
|
repo: "one", paths: []string{"modules/a/x"}, want: "a,x"},
|
||||||
{what: "packages does not order", edges: []inventory.Edge{dep("x", packages, "a")},
|
{what: "packages does not order", edges: []inventory.Edge{dep("x", packages, "a")},
|
||||||
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a,x"},
|
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a,x"},
|
||||||
{what: "built-by never widens", edges: []inventory.Edge{dep("x", builtBy, "a")},
|
{what: "built-by never widens", edges: []inventory.Edge{dep("x", builtBy, "a")},
|
||||||
@@ -191,7 +188,7 @@ func TestAPlanIsWhatTheChangeTouchedAndWhatIsBuiltOnIt(t *testing.T) {
|
|||||||
repo: "one", paths: []string{"modules/z/x"}, want: "z | a,b | d"},
|
repo: "one", paths: []string{"modules/z/x"}, want: "z | a,b | d"},
|
||||||
{what: "a diamond of mixed kinds orders on the ordering side only",
|
{what: "a diamond of mixed kinds orders on the ordering side only",
|
||||||
edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", packages, "b")},
|
edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", packages, "b")},
|
||||||
repo: "one", paths: []string{"modules/b/x"}, want: "b"},
|
repo: "one", paths: []string{"modules/b/x"}, want: "b,d"},
|
||||||
|
|
||||||
// A cycle the catalogue should never produce: what remains is one last tier, and said.
|
// A cycle the catalogue should never produce: what remains is one last tier, and said.
|
||||||
{what: "a cycle is one last tier, not lost", edges: []inventory.Edge{dep("a", standsOn, "b"), dep("b", standsOn, "a"),
|
{what: "a cycle is one last tier, not lost", edges: []inventory.Edge{dep("a", standsOn, "b"), dep("b", standsOn, "a"),
|
||||||
@@ -228,16 +225,22 @@ func TestAPlanIsWhatTheChangeTouchedAndWhatIsBuiltOnIt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// **A shared repository moves only what a change's files are in the build source of** (novox/hq ADR 0267,
|
// **CURRENT BEHAVIOUR, documented — not the rule the operator states.** novox/hq issue 338 (a module
|
||||||
// issue 338, issue 363). The controller is built from its repository's root as a Go bundle; the route proxy
|
// built from a shared repository moves on every merge to it) and the decision pending on it would change
|
||||||
// and the build seat's holder build images whose context is that repository and which name the package they
|
// every row here. Today:
|
||||||
// compile. Each newest trunk build said its build source — the import closure of its program — and a merge
|
|
||||||
// is mapped onto those. The rows tagged 338 held the opposite until ADR 0267 was built: every merge to the
|
|
||||||
// controller's repository planned all three, in three tiers.
|
|
||||||
//
|
//
|
||||||
// The build sources are this repository's own programs as GoBuildSource reads them (held to that by
|
// - mesh-controller is built from its repository's root, so every file of that repository touches it;
|
||||||
// TestThisRepositorysProgramsHaveBuildSourcesOfTheirOwn in internal/builder), cut to what the rows need.
|
// - route-proxy and build-agent package the whole of that repository (a build context), so the build
|
||||||
func TestASharedRepositoryMovesOnlyWhatItsBuildSourceHolds(t *testing.T) {
|
// record's `read` makes them move on any merge to it, whatever the files, and dependenciesOf gives
|
||||||
|
// each a packages edge to every module built from it;
|
||||||
|
// - built-by (route-proxy on build-agent) and worker-of (build-agent on the controller) make it three
|
||||||
|
// tiers.
|
||||||
|
//
|
||||||
|
// These follow ADR 0238 §3 as written ("the whole repository for a module built from its root, and a
|
||||||
|
// repository a recipe names"), so they are not failures; when the decision on issue 338 lands, these
|
||||||
|
// expectations change with it. The edges are the ones dependenciesOf derives from this catalogue — held
|
||||||
|
// to that by TestASharedRepositoryIsPlannedFromTheRecordsAsItIsToday, which derives them from the store.
|
||||||
|
func TestASharedRepositoryMovesWhatPackagesItAsItDoesToday(t *testing.T) {
|
||||||
const catalogueRepo = "http://forge.internal:20000/novox/mesh-catalog.git"
|
const catalogueRepo = "http://forge.internal:20000/novox/mesh-catalog.git"
|
||||||
const controllerRepo = "http://forge.internal:20000/novox/mesh-controller.git"
|
const controllerRepo = "http://forge.internal:20000/novox/mesh-controller.git"
|
||||||
entries := []inventory.Entry{
|
entries := []inventory.Entry{
|
||||||
@@ -246,26 +249,7 @@ func TestASharedRepositoryMovesOnlyWhatItsBuildSourceHolds(t *testing.T) {
|
|||||||
fromRepo("route-proxy", catalogueRepo, "modules/route-proxy"),
|
fromRepo("route-proxy", catalogueRepo, "modules/route-proxy"),
|
||||||
fromRepo("gitea", catalogueRepo, "modules/gitea"),
|
fromRepo("gitea", catalogueRepo, "modules/gitea"),
|
||||||
}
|
}
|
||||||
gomod := []string{"go.mod", "go.sum", "vendor/modules.txt"}
|
|
||||||
with := func(paths ...string) []string { return append(append([]string{}, gomod...), paths...) }
|
|
||||||
read := map[string][]inventory.ReadRepository{
|
read := map[string][]inventory.ReadRepository{
|
||||||
"mesh-controller": {{Own: true, Paths: with("module.json", "cmd/mesh-controller/", "internal/conditions/",
|
|
||||||
"internal/broker/", "internal/builder/", "internal/inventory/", "internal/inventory/migrations/**",
|
|
||||||
"vendor/github.com/nats-io/nats.go/")}},
|
|
||||||
"build-agent": {
|
|
||||||
{Repository: "novox/mesh-controller", Ref: "main", Paths: with("cmd/mesh-builder/", "internal/broker/",
|
|
||||||
"internal/builder/", "internal/inventory/", "internal/inventory/migrations/**", "vendor/github.com/nats-io/nats.go/")},
|
|
||||||
{Own: true, Paths: []string{"modules/build-agent/Dockerfile", "modules/build-agent/module.json"}},
|
|
||||||
},
|
|
||||||
"route-proxy": {
|
|
||||||
{Repository: "novox/mesh-controller", Ref: "main", Paths: with("examples/route-proxy/", "internal/broker/",
|
|
||||||
"vendor/github.com/nats-io/nats.go/")},
|
|
||||||
{Own: true, Paths: []string{"modules/route-proxy/Dockerfile", "modules/route-proxy/module.json"}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
// With no build source said — before each module's first trunk build under ADR 0267, or while an
|
|
||||||
// earlier merge's build of it is pending — a module is read as before.
|
|
||||||
unsaid := map[string][]inventory.ReadRepository{
|
|
||||||
"build-agent": {{Repository: "novox/mesh-controller", Ref: "main"}},
|
"build-agent": {{Repository: "novox/mesh-controller", Ref: "main"}},
|
||||||
"route-proxy": {{Repository: "novox/mesh-controller", Ref: "main"}},
|
"route-proxy": {{Repository: "novox/mesh-controller", Ref: "main"}},
|
||||||
}
|
}
|
||||||
@@ -273,176 +257,51 @@ func TestASharedRepositoryMovesOnlyWhatItsBuildSourceHolds(t *testing.T) {
|
|||||||
for _, c := range []struct {
|
for _, c := range []struct {
|
||||||
what, repo string
|
what, repo string
|
||||||
paths []string
|
paths []string
|
||||||
read map[string][]inventory.ReadRepository
|
|
||||||
want string
|
want string
|
||||||
unread string
|
|
||||||
}{
|
}{
|
||||||
// The operator's acceptance: a merge of the controller's own code plans the controller alone.
|
// The live three-tier plan of 2026-10-08 (issue 338), in the worker-of order (issue 206) that
|
||||||
{"the controller's own command: the controller alone (338)", "mesh-controller",
|
// TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency's controller case holds too.
|
||||||
[]string{"cmd/mesh-controller/main.go"}, read, "mesh-controller", ""},
|
{"a README of the controller's repository moves all three, in three tiers", "mesh-controller",
|
||||||
{"a package only the controller builds from: the controller alone (338)", "mesh-controller",
|
[]string{"README.md"}, "mesh-controller | build-agent | route-proxy"},
|
||||||
[]string{"internal/conditions/condition.go", "internal/conditions/bus.go"}, read, "mesh-controller", ""},
|
{"the controller's own code: the same", "mesh-controller",
|
||||||
{"a test beside the controller's command: nothing is built from it", "mesh-controller",
|
[]string{"cmd/mesh-controller/main.go"}, "mesh-controller | build-agent | route-proxy"},
|
||||||
[]string{"cmd/mesh-controller/main_test.go"}, read, "", "cmd/mesh-controller/main_test.go"},
|
{"the route proxy's program alone: the same, the controller with it", "mesh-controller",
|
||||||
{"a README of the controller's repository: no module (338)", "mesh-controller",
|
[]string{"examples/route-proxy/main.go"}, "mesh-controller | build-agent | route-proxy"},
|
||||||
[]string{"README.md"}, read, "", "README.md"},
|
// In the catalogue, where they live, the rule is path-precise.
|
||||||
{"the route proxy's program alone: the route proxy alone (338)", "mesh-controller",
|
{"the route proxy's directory in the catalogue: it alone", "mesh-catalog",
|
||||||
[]string{"examples/route-proxy/main.go"}, read, "route-proxy", ""},
|
[]string{"modules/route-proxy/module.json"}, "route-proxy"},
|
||||||
{"the build seat's program alone: its holder alone", "mesh-controller",
|
{"the build agent's directory: it alone, nothing it builds", "mesh-catalog",
|
||||||
[]string{"cmd/mesh-builder/main.go"}, read, "build-agent", ""},
|
[]string{"modules/build-agent/module.json"}, "build-agent"},
|
||||||
{"a package the build seat's program and the controller build from: both", "mesh-controller",
|
|
||||||
[]string{"internal/builder/builder.go"}, read, "mesh-controller | build-agent", ""},
|
|
||||||
{"a migration the controller and the build seat's program embed: both", "mesh-controller",
|
|
||||||
[]string{"internal/inventory/migrations/0088-a-build-says-its-build-source.sql"}, read,
|
|
||||||
"mesh-controller | build-agent", ""},
|
|
||||||
// A package all three build from: all three; the build seat's holder after the controller whose worker
|
|
||||||
// it binds (worker-of, issue 206), the proxy after the holder that builds it (built-by).
|
|
||||||
{"a package all three build from: all three", "mesh-controller",
|
|
||||||
[]string{"internal/broker/broker.go"}, read, "mesh-controller | build-agent | route-proxy", ""},
|
|
||||||
{"a vendored package all three build from: all three", "mesh-controller",
|
|
||||||
[]string{"vendor/github.com/nats-io/nats.go/nats.go"}, read, "mesh-controller | build-agent | route-proxy", ""},
|
|
||||||
{"go.sum: all three", "mesh-controller",
|
|
||||||
[]string{"go.sum"}, read, "mesh-controller | build-agent | route-proxy", ""},
|
|
||||||
{"a file added to the proxy's package: the proxy", "mesh-controller",
|
|
||||||
[]string{"examples/route-proxy/new.go"}, read, "route-proxy", ""},
|
|
||||||
// Before any build source is said: as before.
|
|
||||||
{"no build source said: a README moves all three, as before", "mesh-controller",
|
|
||||||
[]string{"README.md"}, unsaid, "mesh-controller | build-agent | route-proxy", ""},
|
|
||||||
// In the catalogue, where they live, each by its own build source.
|
|
||||||
{"the route proxy's recipe: it alone", "mesh-catalog",
|
|
||||||
[]string{"modules/route-proxy/Dockerfile"}, read, "route-proxy", ""},
|
|
||||||
{"the route proxy's manifest: it alone", "mesh-catalog",
|
|
||||||
[]string{"modules/route-proxy/module.json"}, read, "route-proxy", ""},
|
|
||||||
{"the route proxy's README: nothing", "mesh-catalog",
|
|
||||||
[]string{"modules/route-proxy/README.md"}, read, "", "modules/route-proxy/README.md"},
|
|
||||||
{"the build agent's manifest: it alone, nothing it builds", "mesh-catalog",
|
|
||||||
[]string{"modules/build-agent/module.json"}, read, "build-agent", ""},
|
|
||||||
{"another module of the catalogue: neither", "mesh-catalog",
|
{"another module of the catalogue: neither", "mesh-catalog",
|
||||||
[]string{"modules/gitea/index.ts"}, read, "gitea", ""},
|
[]string{"modules/gitea/index.ts"}, "gitea"},
|
||||||
} {
|
} {
|
||||||
r, got := planMerge(t, c.repo, c.paths, entries, c.read, edges)
|
r, got := planMerge(t, c.repo, c.paths, entries, read, edges)
|
||||||
if got != c.want {
|
if got != c.want {
|
||||||
t.Errorf("%s: planned %q, wanted %q", c.what, got, c.want)
|
t.Errorf("%s: planned %q, wanted %q (as today; issue 338)", c.what, got, c.want)
|
||||||
}
|
}
|
||||||
if u := strings.Join(r.Unread, ","); u != c.unread {
|
if c.repo == "mesh-controller" && strings.Join(r.Unread, ",") != "" {
|
||||||
t.Errorf("%s: unread %q, wanted %q", c.what, u, c.unread)
|
t.Errorf("%s: a root-built module reads every file, and %v were said unread", c.what, r.Unread)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Files not all said: everything the repository builds, as before.
|
|
||||||
m := link.SourceMoved{Owner: "novox", Repo: "mesh-controller", Base: "main", Commit: "head",
|
|
||||||
Paths: []string{"README.md"}, PathsTruncated: true}
|
|
||||||
if got := tiered(reachOfMerge(m, entries, read, edges).Plan.Tiers); got != "mesh-controller | build-agent | route-proxy" {
|
|
||||||
t.Errorf("a merge whose files were not all said: planned %q, wanted all three", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **A module whose recorded build source a plan has overtaken is read whole** (novox/hq ADR 0267): a merge
|
|
||||||
// that added an import to the route proxy is planned; before a build of it works — still building, failed,
|
|
||||||
// or its plan closed before reaching it — a merge changing only the newly imported package must still move
|
|
||||||
// the proxy, since the build source its last build said does not hold that package.
|
|
||||||
func TestAModuleAPlanOvertookIsReadWhole(t *testing.T) {
|
|
||||||
built := time.Date(2026, 10, 10, 1, 0, 0, 0, time.UTC)
|
|
||||||
read := map[string][]inventory.ReadRepository{
|
|
||||||
"route-proxy": {
|
|
||||||
{Repository: "novox/mesh-controller", Ref: "main", Paths: []string{"examples/route-proxy/", "go.mod"}, Built: built},
|
|
||||||
{Own: true, Paths: []string{"modules/route-proxy/module.json"}, Built: built},
|
|
||||||
},
|
|
||||||
"mesh-controller": {{Own: true, Paths: []string{"module.json", "cmd/mesh-controller/"}, Built: built}},
|
|
||||||
}
|
|
||||||
m := link.SourceMoved{Owner: "novox", Repo: "mesh-controller", Base: "main", Paths: []string{"internal/newly/imported.go"}}
|
|
||||||
if readsFrom(read["route-proxy"], m) {
|
|
||||||
t.Fatal("the said build source holds the new package: the fixture is wrong")
|
|
||||||
}
|
|
||||||
proxy := func(state string) map[string]*inventory.PlanModule {
|
|
||||||
return map[string]*inventory.PlanModule{"route-proxy": {State: state}, "gitea": {State: "built"}}
|
|
||||||
}
|
|
||||||
for _, c := range []struct {
|
|
||||||
what string
|
|
||||||
plans []inventory.Plan
|
|
||||||
whole bool
|
|
||||||
}{
|
|
||||||
{"no plan", nil, false},
|
|
||||||
{"a plan still building it", []inventory.Plan{{State: inventory.PlanBuilding, Created: built.Add(-time.Hour),
|
|
||||||
Modules: proxy("building")}}, true},
|
|
||||||
{"a plan made after its build that failed before building it", []inventory.Plan{{State: "failed",
|
|
||||||
Created: built.Add(time.Minute), Modules: proxy("waiting")}}, true},
|
|
||||||
{"a plan made after its build that built it", []inventory.Plan{{State: inventory.PlanDone,
|
|
||||||
Created: built.Add(time.Minute), Modules: proxy("built")}}, false},
|
|
||||||
{"a plan closed before its build", []inventory.Plan{{State: "failed", Created: built.Add(-time.Hour),
|
|
||||||
Modules: proxy("waiting")}}, false},
|
|
||||||
} {
|
|
||||||
view := planningView(read, c.plans)
|
|
||||||
if readsFrom(view["route-proxy"], m) != c.whole {
|
|
||||||
t.Errorf("%s: read whole %v, wanted %v", c.what, !c.whole, c.whole)
|
|
||||||
}
|
|
||||||
if (ownSource(view["route-proxy"]) == nil) != c.whole {
|
|
||||||
t.Errorf("%s: its own build source kept %v", c.what, ownSource(view["route-proxy"]) != nil)
|
|
||||||
}
|
|
||||||
if ownSource(view["mesh-controller"]) == nil {
|
|
||||||
t.Errorf("%s: a module no plan holds lost its build source", c.what)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **A missed merge that moves only a module packaging the repository is acted on** (novox/hq ADR 0267,
|
|
||||||
// issue 266): the catch-up asks wouldMove, which counts it; once a plan or build of it is made after the
|
|
||||||
// merge, the merge is history for it, and the catch-up leaves it.
|
|
||||||
func TestAMissedMergeMovingOnlyAPackagingModuleIsActedOnOnce(t *testing.T) {
|
|
||||||
merged := time.Date(2026, 10, 10, 1, 0, 0, 0, time.UTC)
|
|
||||||
entries := []inventory.Entry{
|
|
||||||
fromRepo("mesh-controller", "http://forge.internal:20000/novox/mesh-controller.git", ""),
|
|
||||||
fromRepo("route-proxy", "http://forge.internal:20000/novox/mesh-catalog.git", "modules/route-proxy"),
|
|
||||||
}
|
|
||||||
read := map[string][]inventory.ReadRepository{
|
|
||||||
"mesh-controller": {{Own: true, Paths: []string{"module.json", "cmd/mesh-controller/"}, Built: merged.Add(-time.Hour)}},
|
|
||||||
"route-proxy": {{Repository: "novox/mesh-controller", Ref: "main", Paths: []string{"examples/route-proxy/"},
|
|
||||||
Built: merged.Add(-time.Hour), Looked: merged.Add(-time.Hour)}},
|
|
||||||
}
|
|
||||||
m := link.SourceMoved{Owner: "novox", Repo: "mesh-controller", Base: "main", Commit: "c1",
|
|
||||||
MergedAt: merged.Format(time.RFC3339), Paths: []string{"examples/route-proxy/main.go"}}
|
|
||||||
if got := wouldMove(m, entries, planningView(read, nil)); len(got) != 1 || got[0].Manifest.Module != "route-proxy" {
|
|
||||||
t.Fatalf("a missed merge of the proxy's program would move %v", got)
|
|
||||||
}
|
|
||||||
// Acted on at once: the plan answering this very merge is a look, however close the clocks.
|
|
||||||
atOnce := []inventory.Plan{{State: inventory.PlanBuilding, Commit: "c1", Created: merged.Add(2 * time.Second),
|
|
||||||
Modules: map[string]*inventory.PlanModule{"route-proxy": {State: "building"}}}}
|
|
||||||
if got := wouldMove(m, entries, planningView(read, atOnce)); len(got) != 0 {
|
|
||||||
t.Fatalf("a merge whose own plan holds the proxy would move %v again", got)
|
|
||||||
}
|
|
||||||
acted := []inventory.Plan{{State: inventory.PlanBuilding, Created: merged.Add(2 * time.Minute),
|
|
||||||
Modules: map[string]*inventory.PlanModule{"route-proxy": {State: "building"}}}}
|
|
||||||
if got := wouldMove(m, entries, planningView(read, acted)); len(got) != 0 {
|
|
||||||
t.Fatalf("a merge acted on for the proxy would move %v again", got)
|
|
||||||
}
|
|
||||||
// A plan that closed without building it looked at nothing: the merge is still news for it.
|
|
||||||
closed := []inventory.Plan{{State: "failed", Created: merged.Add(2 * time.Minute),
|
|
||||||
Modules: map[string]*inventory.PlanModule{"route-proxy": {State: "waiting"}}}}
|
|
||||||
if got := wouldMove(m, entries, planningView(read, closed)); len(got) != 1 {
|
|
||||||
t.Fatalf("a plan that never built the proxy hid the merge from it: %v", got)
|
|
||||||
}
|
|
||||||
// A look just before the merge, on clocks a little apart, is no look after it.
|
|
||||||
skewed := []inventory.Plan{{State: inventory.PlanBuilding, Created: merged.Add(30 * time.Second),
|
|
||||||
Modules: map[string]*inventory.PlanModule{"route-proxy": {State: "building"}}}}
|
|
||||||
if got := wouldMove(m, entries, planningView(read, skewed)); len(got) != 1 {
|
|
||||||
t.Fatalf("a look within the clocks' margin made the merge history: %v", got)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sharedRepositoryEdges is what dependenciesOf derives for the catalogue of the test above, sorted as it
|
// sharedRepositoryEdges is what dependenciesOf derives for the catalogue of the test above, sorted as it
|
||||||
// sorts them: no packages edge (novox/hq ADR 0267 rule 4).
|
// sorts them.
|
||||||
var sharedRepositoryEdges = []inventory.Edge{
|
var sharedRepositoryEdges = []inventory.Edge{
|
||||||
|
dep("build-agent", inventory.EdgePackages, "mesh-controller"),
|
||||||
dep("build-agent", inventory.EdgeWorkerOf, "mesh-controller"),
|
dep("build-agent", inventory.EdgeWorkerOf, "mesh-controller"),
|
||||||
dep("gitea", inventory.EdgeBuiltBy, "build-agent"),
|
dep("gitea", inventory.EdgeBuiltBy, "build-agent"),
|
||||||
dep("mesh-controller", inventory.EdgeBuiltBy, "build-agent"),
|
dep("mesh-controller", inventory.EdgeBuiltBy, "build-agent"),
|
||||||
dep("route-proxy", inventory.EdgeBuiltBy, "build-agent"),
|
dep("route-proxy", inventory.EdgeBuiltBy, "build-agent"),
|
||||||
|
dep("route-proxy", inventory.EdgePackages, "mesh-controller"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// **The planner's invariant, over random catalogues.** For any catalogue whose dependencies form no cycle
|
// **The planner's invariant, over random catalogues.** For any catalogue whose dependencies form no cycle
|
||||||
// and any set of changed files in one repository:
|
// and any set of changed files in one repository:
|
||||||
//
|
//
|
||||||
// - the plan is exactly the modules of that repository whose directory holds a changed file (every file,
|
// - the plan is exactly the modules of that repository whose directory holds a changed file (every file,
|
||||||
// for a module built from the root), and everything reachable from them along stands-on and declared —
|
// for a module built from the root), and everything reachable from them along stands-on, declared and
|
||||||
// never along packages (novox/hq ADR 0267), built-by or worker-of;
|
// packages — never along built-by or worker-of;
|
||||||
// - every stands-on, declared, built-by and worker-of edge with both ends in the plan has the module
|
// - every stands-on, declared, built-by and worker-of edge with both ends in the plan has the module
|
||||||
// depended on in an earlier tier;
|
// depended on in an earlier tier;
|
||||||
// - no cycle is said.
|
// - no cycle is said.
|
||||||
@@ -451,7 +310,7 @@ var sharedRepositoryEdges = []inventory.Edge{
|
|||||||
func TestAPlanIsTheTouchedModulesAndWhatIsReachableAlongTheWideningEdges(t *testing.T) {
|
func TestAPlanIsTheTouchedModulesAndWhatIsReachableAlongTheWideningEdges(t *testing.T) {
|
||||||
kinds := []string{inventory.EdgeStandsOn, inventory.EdgeDeclared, inventory.EdgePackages,
|
kinds := []string{inventory.EdgeStandsOn, inventory.EdgeDeclared, inventory.EdgePackages,
|
||||||
inventory.EdgeBuiltBy, inventory.EdgeWorkerOf}
|
inventory.EdgeBuiltBy, inventory.EdgeWorkerOf}
|
||||||
widens := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true}
|
widens := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true, inventory.EdgePackages: true}
|
||||||
orders := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true,
|
orders := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true,
|
||||||
inventory.EdgeBuiltBy: true, inventory.EdgeWorkerOf: true}
|
inventory.EdgeBuiltBy: true, inventory.EdgeWorkerOf: true}
|
||||||
// Directory names drawn from one pool, so two repositories hold directories of the same name, and one
|
// Directory names drawn from one pool, so two repositories hold directories of the same name, and one
|
||||||
@@ -586,43 +445,3 @@ func describe(entries []inventory.Entry) []string {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// **The merge gate reads the build sources the snapshot carries** (novox/hq ADR 0267): the gate's plan of a
|
|
||||||
// change is the merge handler's, so a snapshot taken by a controller that records build sources narrows the
|
|
||||||
// gate's plan as it narrows the merge's; one without them reads every module as before.
|
|
||||||
func TestTheGatePlansFromTheBuildSourcesTheSnapshotCarries(t *testing.T) {
|
|
||||||
manifest := func(name string) json.RawMessage { return json.RawMessage(`{"module":"` + name + `","version":"1"}`) }
|
|
||||||
facts := snapshot.Facts{Modules: []snapshot.Module{
|
|
||||||
{Name: "mesh-controller", Repository: "novox/mesh-controller", Manifest: manifest("mesh-controller"),
|
|
||||||
Sources: []snapshot.BuildSource{{Own: true, Paths: []string{"module.json", "cmd/mesh-controller/", "internal/broker/"}}}},
|
|
||||||
{Name: "route-proxy", Repository: "novox/mesh-catalog", Path: "modules/route-proxy", Manifest: manifest("route-proxy"),
|
|
||||||
Reads: []string{"novox/mesh-controller"},
|
|
||||||
Sources: []snapshot.BuildSource{{Repository: "novox/mesh-controller", Paths: []string{"examples/route-proxy/", "internal/broker/"}},
|
|
||||||
{Own: true, Paths: []string{"modules/route-proxy/module.json"}}}},
|
|
||||||
}}
|
|
||||||
for paths, want := range map[string]string{
|
|
||||||
"cmd/mesh-controller/main.go": "mesh-controller",
|
|
||||||
"examples/route-proxy/main.go": "route-proxy",
|
|
||||||
"internal/broker/broker.go": "mesh-controller,route-proxy",
|
|
||||||
"README.md": "",
|
|
||||||
} {
|
|
||||||
r, err := reachOfChange(facts, "novox/mesh-controller", []string{paths}, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got := tiered(r.Plan.Tiers); got != want {
|
|
||||||
t.Errorf("%s: the gate planned %q, wanted %q", paths, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A snapshot without build sources: as before.
|
|
||||||
for i := range facts.Modules {
|
|
||||||
facts.Modules[i].Sources = nil
|
|
||||||
}
|
|
||||||
r, err := reachOfChange(facts, "novox/mesh-controller", []string{"README.md"}, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got := tiered(r.Plan.Tiers); got != "mesh-controller,route-proxy" {
|
|
||||||
t.Errorf("a snapshot without build sources: the gate planned %q for a README", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -138,10 +138,9 @@ func reachableFrom(moved []string, edges []inventory.Edge) []string {
|
|||||||
grew = false
|
grew = false
|
||||||
for _, e := range edges {
|
for _, e := range edges {
|
||||||
// Built-by and worker-of order a plan; neither widens it. A new build machine changes
|
// Built-by and worker-of order a plan; neither widens it. A new build machine changes
|
||||||
// nothing it builds, and a new controller changes nothing about the holder it orders. A
|
// nothing it builds, and a new controller changes nothing about the holder it orders —
|
||||||
// packages edge, read from a record made before novox/hq ADR 0267, widens nothing either:
|
// what packages the controller's source is already a code edge.
|
||||||
// a shared file moves each module whose build source holds it, directly.
|
if e.Kind == inventory.EdgeBuiltBy || e.Kind == inventory.EdgeWorkerOf {
|
||||||
if e.Kind == inventory.EdgeBuiltBy || e.Kind == inventory.EdgeWorkerOf || e.Kind == inventory.EdgePackages {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if in[e.To] && !in[e.From] {
|
if in[e.To] && !in[e.From] {
|
||||||
@@ -1562,7 +1561,7 @@ func planWhatIf(ctx context.Context, inv *inventory.Inventory, repository string
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
read, err := readForPlanning(ctx, inv)
|
read, err := inv.ReadRepositories(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,18 +59,17 @@ func TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency(t *testing.T) {
|
|||||||
t.Fatalf("no cycle here: %v", tiers)
|
t.Fatalf("no cycle here: %v", tiers)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The controller alone moved: the controller alone — what packages its repository moves only when the
|
// The controller alone moved: the proxy with it, nothing else.
|
||||||
// change is in its own build source (novox/hq ADR 0267), so a packages edge widens nothing.
|
small := reachableFrom([]string{"mesh-controller"}, edges)
|
||||||
if alone := reachableFrom([]string{"mesh-controller"}, edges); len(alone) != 1 {
|
if len(small) != 3 {
|
||||||
t.Fatalf("a controller merge rebuilds the controller alone: %v", alone)
|
t.Fatalf("a controller merge rebuilds the controller and what packages it: %v", small)
|
||||||
}
|
}
|
||||||
// A change to a package all three build from moves all three. The builder holds
|
// The builder and the proxy package the controller's source, which orders nothing. The builder holds
|
||||||
// the build seat, whose worker the controller defines, so it follows the controller (worker-of,
|
// the build seat, whose worker the controller defines, so it follows the controller (worker-of,
|
||||||
// novox/hq issue 206), and the controller's built-by edge to it yields: the controller is built by the
|
// novox/hq issue 206), and the controller's built-by edge to it yields: the controller is built by the
|
||||||
// build machine that is running. The proxy is built by the new builder: the controller, the builder,
|
// build machine that is running. The proxy is built by the new builder: the controller, the builder,
|
||||||
// the proxy — the live plan of every controller merge. (This read "the builder, then the controller
|
// the proxy — the live plan of every controller merge. (This read "the builder, then the controller
|
||||||
// and the proxy together" before issue 206, and the fixture had no worker-of edge.)
|
// and the proxy together" before issue 206, and the fixture had no worker-of edge.)
|
||||||
small := reachableFrom([]string{"mesh-controller", "builder", "route-proxy"}, edges)
|
|
||||||
smallTiers := tiersOf(small, edges)
|
smallTiers := tiersOf(small, edges)
|
||||||
if got := tiered(smallTiers); got != "mesh-controller | builder | route-proxy" {
|
if got := tiered(smallTiers); got != "mesh-controller | builder | route-proxy" {
|
||||||
t.Fatalf("the controller, then the builder, then the proxy: %v", smallTiers)
|
t.Fatalf("the controller, then the builder, then the proxy: %v", smallTiers)
|
||||||
@@ -244,7 +243,7 @@ func TestAChangeToTheBuildAgentRebuildsTheBuildAgentAlone(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "abc", Paths: paths,
|
m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "abc", Paths: paths,
|
||||||
ModuleDirs: []string{"modules/build-agent"}, ModuleDirsSaid: true}
|
ModuleDirs: []string{"modules/build-agent"}, ModuleDirsSaid: true}
|
||||||
touched := whatTheMergeTouched(entries, entries, m, nil)
|
touched := whatTheMergeTouched(entries, entries, m)
|
||||||
if len(touched) != 1 || touched[0].Manifest.Module != "build-agent" {
|
if len(touched) != 1 || touched[0].Manifest.Module != "build-agent" {
|
||||||
t.Fatalf("%v touched %v", paths, touched)
|
t.Fatalf("%v touched %v", paths, touched)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -536,6 +536,11 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
|||||||
argv = append(argv, "--condition", c)
|
argv = append(argv, "--condition", c)
|
||||||
}
|
}
|
||||||
return argv, nil
|
return argv, nil
|
||||||
|
case "warranted":
|
||||||
|
if err := need("asker", "ask"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []string{"hand-act", "warrant", "--asker", str("asker"), "--ask", str("ask")}, nil
|
||||||
case "hand-acts":
|
case "hand-acts":
|
||||||
argv := []string{"hand-acts", "--json"}
|
argv := []string{"hand-acts", "--json"}
|
||||||
if d := str("days"); d != "" {
|
if d := str("days"); d != "" {
|
||||||
@@ -935,6 +940,8 @@ func repairingCommand(argv []string) string {
|
|||||||
return "plans " + argv[1]
|
return "plans " + argv[1]
|
||||||
case argv[0] == "broker" && len(argv) > 1 && argv[1] == "consumer-reset":
|
case argv[0] == "broker" && len(argv) > 1 && argv[1] == "consumer-reset":
|
||||||
return "broker consumer-reset"
|
return "broker consumer-reset"
|
||||||
|
case argv[0] == "hand-act" && len(argv) > 1 && argv[1] == "warrant":
|
||||||
|
return "" // the router's record of a person's answer, never a repair (novox/hq ADR 0274)
|
||||||
case argv[0] == "hand-act" && len(argv) > 1 && argv[1] == "drill":
|
case argv[0] == "hand-act" && len(argv) > 1 && argv[1] == "drill":
|
||||||
return "hand-act drill"
|
return "hand-act drill"
|
||||||
case argv[0] == "hand-act":
|
case argv[0] == "hand-act":
|
||||||
|
|||||||
+41
-212
@@ -12,7 +12,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/builder"
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
@@ -314,7 +313,7 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return notNow(err)
|
return notNow(err)
|
||||||
}
|
}
|
||||||
read, err := readForPlanning(ctx, inv)
|
read, err := inv.ReadRepositories(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return notNow(err)
|
return notNow(err)
|
||||||
}
|
}
|
||||||
@@ -347,6 +346,12 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
|||||||
m.Owner, m.Repo, m.Base, m.Commit)
|
m.Owner, m.Repo, m.Base, m.Commit)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// The same judgement for the packaging kind, against the newest look at that repository by
|
||||||
|
// anything built from it: they keep no record of it themselves, and a replayed old merge should
|
||||||
|
// not rebuild them either.
|
||||||
|
if isHistory(m.MergedAt, lastLookAt(entries, m)) {
|
||||||
|
packaging = nil
|
||||||
|
}
|
||||||
// Said, never silent (novox/hq 04-ISSUES/215): a module built from this repository that follows
|
// Said, never silent (novox/hq 04-ISSUES/215): a module built from this repository that follows
|
||||||
// another branch is not part of this merge, and whoever is waiting for its change should read why.
|
// another branch is not part of this merge, and whoever is waiting for its change should read why.
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
@@ -355,7 +360,7 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
|||||||
e.Manifest.Module, m.Owner, m.Repo, e.Source.Ref, m.Base)
|
e.Manifest.Module, m.Owner, m.Repo, e.Source.Ref, m.Base)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
touched, added, _ := touchedBy(from, entries, m, read)
|
touched, added, _ := touchedBy(from, entries, m)
|
||||||
// **A module the merge deleted is not built** (novox/hq ADR 0236): its manifest is gone, so the build
|
// **A module the merge deleted is not built** (novox/hq ADR 0236): its manifest is gone, so the build
|
||||||
// seat finds nothing saying what it is, and the plan failed on it (`has no module.json at …`) with
|
// seat finds nothing saying what it is, and the plan failed on it (`has no module.json at …`) with
|
||||||
// every other module of its tier left unsent. It is forgotten where nothing holds it, said otherwise.
|
// every other module of its tier left unsent. It is forgotten where nothing holds it, said otherwise.
|
||||||
@@ -565,62 +570,25 @@ func mergeCandidates(m link.SourceMoved, entries []inventory.Entry,
|
|||||||
}
|
}
|
||||||
from = append(from, e)
|
from = append(from, e)
|
||||||
case readsFrom(read[e.Manifest.Module], m):
|
case readsFrom(read[e.Manifest.Module], m):
|
||||||
// **A merge older than the module's last look is history for it** (novox/hq ADR 0267): a
|
|
||||||
// build or plan of it after the merge already read the repository with the merge in it. Per
|
|
||||||
// module, since a merge that moved only the module built from the repository says nothing
|
|
||||||
// about the ones packaging it.
|
|
||||||
// Judged with a margin for the forge's clock running behind the store's: too late a look
|
|
||||||
// rebuilds once more, too early one would miss the merge.
|
|
||||||
if lookedAtCommit(read[e.Manifest.Module], m.Commit) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if looked := lookedOf(read[e.Manifest.Module]); !looked.IsZero() &&
|
|
||||||
isHistory(m.MergedAt, looked.Add(-historyMargin)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
packaging = append(packaging, e)
|
packaging = append(packaging, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return from, packaging, already
|
return from, packaging, already
|
||||||
}
|
}
|
||||||
|
|
||||||
// lookedAtCommit is whether a plan that built a module, or is building it, answered this merge commit.
|
// wouldMove is the modules built from the merged repository that acting on this merge would mark as
|
||||||
func lookedAtCommit(read []inventory.ReadRepository, commit string) bool {
|
// moved and rebuild — SourceMoved's judgement, made without acting (novox/hq issue 266). Empty for a
|
||||||
for _, r := range read {
|
// merge already acted on: acting marks each of them as looked at, so the merge then reads as history.
|
||||||
if slices.Contains(r.LookedAt, commit) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// historyMargin is how far a packaging module's last look is taken back before a merge is history for it.
|
|
||||||
const historyMargin = time.Minute
|
|
||||||
|
|
||||||
// lookedOf is when a module packaging another repository was last looked at, as readForPlanning says.
|
|
||||||
func lookedOf(read []inventory.ReadRepository) time.Time {
|
|
||||||
var at time.Time
|
|
||||||
for _, r := range read {
|
|
||||||
if r.Looked.After(at) {
|
|
||||||
at = r.Looked
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return at
|
|
||||||
}
|
|
||||||
|
|
||||||
// wouldMove is the modules acting on this merge would move and rebuild — SourceMoved's judgement, made
|
|
||||||
// without acting (novox/hq issue 266). Empty for a merge already acted on: acting marks each module built
|
|
||||||
// from the repository as looked at, so the merge then reads as history for it.
|
|
||||||
//
|
//
|
||||||
// **The ones packaging source from it too** (novox/hq ADR 0267): with a module moved only by the files of
|
// **Only the modules built from it, never the ones that merely package source from it.** Acting
|
||||||
// its build source, a merge can move a packaging module and nothing built from the repository, and a missed
|
// records nothing about those, so a merge acted on would go on reading as unacted for them, and be
|
||||||
// one of those was never acted on. A packaging module's look is its newest build or plan (lookedAt), so a
|
// acted on again on every look. A merge that moves both is caught by the first kind, and acting on it
|
||||||
// merge acted on for it reads as history once its plan is made.
|
// rebuilds the second as well.
|
||||||
func wouldMove(m link.SourceMoved, entries []inventory.Entry,
|
func wouldMove(m link.SourceMoved, entries []inventory.Entry,
|
||||||
read map[string][]inventory.ReadRepository) []inventory.Entry {
|
read map[string][]inventory.ReadRepository) []inventory.Entry {
|
||||||
from, packaging, _ := mergeCandidates(m, entries, read)
|
from, _, _ := mergeCandidates(m, entries, read)
|
||||||
touched, _ := splitDeleted(whatTheMergeTouched(from, entries, m, read), m)
|
touched, _ := splitDeleted(whatTheMergeTouched(from, entries, m), m)
|
||||||
return append(touched, packaging...)
|
return touched
|
||||||
}
|
}
|
||||||
|
|
||||||
// splitDeleted parts the modules a merge touched into those it changed and those whose manifest it
|
// splitDeleted parts the modules a merge touched into those it changed and those whose manifest it
|
||||||
@@ -704,161 +672,34 @@ func sameRepository(repository string, m link.SourceMoved) bool {
|
|||||||
(m.CloneURL != "" && repo == strings.ToLower(strings.TrimSuffix(m.CloneURL, ".git")))
|
(m.CloneURL != "" && repo == strings.ToLower(strings.TrimSuffix(m.CloneURL, ".git")))
|
||||||
}
|
}
|
||||||
|
|
||||||
// readsFrom is whether a merge changed what a module's build read in another repository: the second
|
// readsFrom is whether a module's build read the repository a merge names: the second repository its
|
||||||
// repository its recipe packages source from. Its ref must be the branch that moved, or unset — the same
|
// recipe packages source from. Its ref must be the branch that moved, or unset — the same rule a
|
||||||
// rule a module's own source follows.
|
// module's own source follows.
|
||||||
//
|
|
||||||
// **Only a changed file in what the build read there** (novox/hq ADR 0267 rule 2): where the module's
|
|
||||||
// newest trunk build said its build source in that repository, a merge touching none of it is no change
|
|
||||||
// to the module (issue 338: every merge to the controller's repository moved the route proxy and the
|
|
||||||
// build seat's holder). Where it said none, or the merge's files are not all said, the whole repository is
|
|
||||||
// read, as before.
|
|
||||||
func readsFrom(read []inventory.ReadRepository, m link.SourceMoved) bool {
|
func readsFrom(read []inventory.ReadRepository, m link.SourceMoved) bool {
|
||||||
for _, r := range read {
|
for _, r := range read {
|
||||||
if r.Own || !sameRepository(r.Repository, m) || (r.Ref != "" && r.Ref != m.Base) {
|
if sameRepository(r.Repository, m) && (r.Ref == "" || r.Ref == m.Base) {
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(r.Paths) == 0 || len(m.Paths) == 0 || m.PathsTruncated {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
for _, p := range m.Paths {
|
|
||||||
if builder.SourceHolds(r.Paths, p) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ownSource is the build source a module's newest trunk build said it read in its own repository; nil
|
// lastLookAt is the most recent look at this repository by anything built from it.
|
||||||
// when it said none, and the module's own directory — or, built from the root, its whole repository — is
|
func lastLookAt(entries []inventory.Entry, m link.SourceMoved) time.Time {
|
||||||
// its build source, as before (novox/hq ADR 0267).
|
var newest time.Time
|
||||||
func ownSource(read []inventory.ReadRepository) []string {
|
for _, e := range entries {
|
||||||
for _, r := range read {
|
if sameRepository(e.Source.Repository, m) && e.Source.Seen.After(newest) {
|
||||||
if r.Own && len(r.Paths) > 0 {
|
newest = e.Source.Seen
|
||||||
return r.Paths
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return newest
|
||||||
}
|
|
||||||
|
|
||||||
// readsFile is whether a module built from the merged repository reads one of its changed files: in its
|
|
||||||
// build source where its newest trunk build said one, else anywhere in its directory, or anywhere at all
|
|
||||||
// for a module built from the repository's root.
|
|
||||||
func readsFile(e inventory.Entry, read []inventory.ReadRepository, p string) bool {
|
|
||||||
if own := ownSource(read); own != nil {
|
|
||||||
return builder.SourceHolds(own, p)
|
|
||||||
}
|
|
||||||
return strings.Trim(e.Source.Path, "/") == "" || inside(p, e.Source.Path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// staleIn is the modules whose recorded build source a plan has overtaken (novox/hq ADR 0267): a plan still
|
|
||||||
// working that has yet to build one, or a plan made after that build which never built it — failed, stopped
|
|
||||||
// or superseded. What such a module is built from is changing, or changed without a build to say so: a merge
|
|
||||||
// that added an import to it, and a later one changing only what that import names, would otherwise move
|
|
||||||
// nothing. Each is read whole, as before, until a build of it works again.
|
|
||||||
func staleIn(read map[string][]inventory.ReadRepository, plans []inventory.Plan) map[string]bool {
|
|
||||||
stale := map[string]bool{}
|
|
||||||
for name, rs := range read {
|
|
||||||
var since time.Time
|
|
||||||
for _, r := range rs {
|
|
||||||
if r.Built.After(since) {
|
|
||||||
since = r.Built
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, p := range plans {
|
|
||||||
s, in := p.Modules[name]
|
|
||||||
if !in || (s != nil && (s.State == "built" || s.State == planDeleted)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if p.Open() || p.Created.After(since) {
|
|
||||||
stale[name] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return stale
|
|
||||||
}
|
|
||||||
|
|
||||||
// lookedAt is when a merge was last acted on for a module that packages another repository's source: its
|
|
||||||
// newest build, or the newest plan that built it or is still building it, whichever is later. A build asked
|
|
||||||
// after a merge clones that repository with the merge in it, so an older merge is history for it; a plan
|
|
||||||
// that closed without building it looked at nothing.
|
|
||||||
func lookedAt(name string, read []inventory.ReadRepository, plans []inventory.Plan) time.Time {
|
|
||||||
var at time.Time
|
|
||||||
for _, r := range read {
|
|
||||||
if r.Looked.After(at) {
|
|
||||||
at = r.Looked
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, p := range plans {
|
|
||||||
s, in := p.Modules[name]
|
|
||||||
if in && (p.Open() || (s != nil && s.State == "built")) && p.Created.After(at) {
|
|
||||||
at = p.Created
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return at
|
|
||||||
}
|
|
||||||
|
|
||||||
// readForPlanning is what each module's build read, as the planner maps a change onto it — for a merge
|
|
||||||
// acting now, the merge gate, a pull request's check, a delivery's order and the what-if alike, so planning
|
|
||||||
// and gating cannot disagree (novox/hq ADR 0238): the build sources the newest trunk builds said, but for
|
|
||||||
// the modules a plan has overtaken (staleIn), and with when each was last looked at (lookedAt).
|
|
||||||
func readForPlanning(ctx context.Context, inv *inventory.Inventory) (map[string][]inventory.ReadRepository, error) {
|
|
||||||
read, err := inv.ReadRepositories(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// Every plan since the oldest build whose source is recorded: one made after a module's build can have
|
|
||||||
// overtaken it, however long ago, so no window of recent plans would do.
|
|
||||||
var oldest time.Time
|
|
||||||
for _, rs := range read {
|
|
||||||
for _, r := range rs {
|
|
||||||
if !r.Built.IsZero() && (oldest.IsZero() || r.Built.Before(oldest)) {
|
|
||||||
oldest = r.Built
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
plans, err := inv.PlansSince(ctx, oldest)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return planningView(read, plans), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// planningView is readForPlanning over what was read, so a test can hand it records.
|
|
||||||
func planningView(read map[string][]inventory.ReadRepository, plans []inventory.Plan) map[string][]inventory.ReadRepository {
|
|
||||||
stale := staleIn(read, plans)
|
|
||||||
out := make(map[string][]inventory.ReadRepository, len(read))
|
|
||||||
for name, rs := range read {
|
|
||||||
looked := lookedAt(name, rs, plans)
|
|
||||||
var commits []string
|
|
||||||
for _, p := range plans {
|
|
||||||
if st, in := p.Modules[name]; in && p.Commit != "" && (p.Open() || (st != nil && st.State == "built")) {
|
|
||||||
commits = append(commits, p.Commit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var kept []inventory.ReadRepository
|
|
||||||
for _, r := range rs {
|
|
||||||
if stale[name] {
|
|
||||||
if r.Own {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
r.Paths = nil
|
|
||||||
}
|
|
||||||
r.Looked, r.LookedAt = looked, commits
|
|
||||||
kept = append(kept, r)
|
|
||||||
}
|
|
||||||
out[name] = kept
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// whatTheMergeTouched narrows the modules built from a repository to the ones the merge changed: **a
|
// whatTheMergeTouched narrows the modules built from a repository to the ones the merge changed: **a
|
||||||
// changed file touches exactly the modules whose build reads it** (novox/hq issue 280, ADR 0238). It is
|
// changed file touches exactly the modules whose build reads it** (novox/hq issue 280, ADR 0238). It is
|
||||||
// touchedBy's first answer; touchedBy is the one place the mesh maps a changed file onto its modules.
|
// touchedBy's first answer; touchedBy is the one place the mesh maps a changed file onto its modules.
|
||||||
func whatTheMergeTouched(candidates, known []inventory.Entry, m link.SourceMoved,
|
func whatTheMergeTouched(candidates, known []inventory.Entry, m link.SourceMoved) []inventory.Entry {
|
||||||
read map[string][]inventory.ReadRepository) []inventory.Entry {
|
touched, _, _ := touchedBy(candidates, known, m)
|
||||||
touched, _, _ := touchedBy(candidates, known, m, read)
|
|
||||||
return touched
|
return touched
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -866,11 +707,8 @@ func whatTheMergeTouched(candidates, known []inventory.Entry, m link.SourceMoved
|
|||||||
// merge handler, the release planner's what-if, the merge gate and a pull request's check alike (novox/hq
|
// merge handler, the release planner's what-if, the merge gate and a pull request's check alike (novox/hq
|
||||||
// ADR 0238), so planning and gating cannot disagree about what a change touches.
|
// ADR 0238), so planning and gating cannot disagree about what a change touches.
|
||||||
//
|
//
|
||||||
// **A changed file touches exactly the modules whose build reads it.** What a build reads is its build
|
// **A changed file touches exactly the modules whose build reads it.** What a build reads is the module's
|
||||||
// source, where the module's newest trunk build said one (novox/hq ADR 0267): a Go program's import closure,
|
// own directory — the builder clones the repository and builds within that directory alone: the manifest,
|
||||||
// an archive's directory, a recipe, its manifest — so a README at the root of a repository whose module is
|
|
||||||
// built from its root, or another program's package beside it, touches nothing. Where none was said, it is
|
|
||||||
// the module's own directory — the builder clones the repository and builds within that directory alone: the manifest,
|
|
||||||
// the recipes, the bundles' sources, the Docker context — or the whole repository for a module built from
|
// the recipes, the bundles' sources, the Docker context — or the whole repository for a module built from
|
||||||
// its root. A second repository a recipe packages (an artifact's `context`) is read too; that is the build
|
// its root. A second repository a recipe packages (an artifact's `context`) is read too; that is the build
|
||||||
// record's `read`, answered by readsFrom in mergeCandidates. So a changed file inside a module's directory
|
// record's `read`, answered by readsFrom in mergeCandidates. So a changed file inside a module's directory
|
||||||
@@ -890,8 +728,7 @@ func whatTheMergeTouched(candidates, known []inventory.Entry, m link.SourceMoved
|
|||||||
//
|
//
|
||||||
// Nothing said about the files, or not all of them said, is still everything: what is not known cannot
|
// Nothing said about the files, or not all of them said, is still everything: what is not known cannot
|
||||||
// be narrowed.
|
// be narrowed.
|
||||||
func touchedBy(candidates, known []inventory.Entry, m link.SourceMoved,
|
func touchedBy(candidates, known []inventory.Entry, m link.SourceMoved) (touched []inventory.Entry, added, unread []string) {
|
||||||
read map[string][]inventory.ReadRepository) (touched []inventory.Entry, added, unread []string) {
|
|
||||||
knownDirs := map[string]bool{}
|
knownDirs := map[string]bool{}
|
||||||
for _, e := range known {
|
for _, e := range known {
|
||||||
if !e.Provided && sameRepository(e.Source.Repository, m) {
|
if !e.Provided && sameRepository(e.Source.Repository, m) {
|
||||||
@@ -926,27 +763,19 @@ func touchedBy(candidates, known []inventory.Entry, m link.SourceMoved,
|
|||||||
return candidates, added, nil
|
return candidates, added, nil
|
||||||
}
|
}
|
||||||
for _, e := range candidates {
|
for _, e := range candidates {
|
||||||
for _, p := range m.Paths {
|
if strings.Trim(e.Source.Path, "/") == "" || anyInside(m.Paths, e.Source.Path) {
|
||||||
if readsFile(e, read[e.Manifest.Module], p) {
|
touched = append(touched, e)
|
||||||
touched = append(touched, e)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, p := range m.Paths {
|
for _, p := range m.Paths {
|
||||||
isRead := newDir["."]
|
read := newDir["."]
|
||||||
for _, e := range candidates {
|
for _, e := range candidates {
|
||||||
isRead = isRead || readsFile(e, read[e.Manifest.Module], p)
|
read = read || strings.Trim(e.Source.Path, "/") == "" || inside(p, e.Source.Path)
|
||||||
}
|
}
|
||||||
for d := range newDir {
|
for d := range newDir {
|
||||||
isRead = isRead || inside(p, d)
|
read = read || inside(p, d)
|
||||||
}
|
}
|
||||||
// A file a module packages from this repository is read too, by that module's build.
|
if !read {
|
||||||
for _, e := range known {
|
|
||||||
isRead = isRead || readsFrom(read[e.Manifest.Module], link.SourceMoved{Owner: m.Owner, Repo: m.Repo,
|
|
||||||
Base: m.Base, CloneURL: m.CloneURL, Paths: []string{p}})
|
|
||||||
}
|
|
||||||
if !isRead {
|
|
||||||
unread = append(unread, p)
|
unread = append(unread, p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1005,7 +834,7 @@ func (r mergeReach) Dependents() []string {
|
|||||||
func reachOfMerge(m link.SourceMoved, entries []inventory.Entry, read map[string][]inventory.ReadRepository,
|
func reachOfMerge(m link.SourceMoved, entries []inventory.Entry, read map[string][]inventory.ReadRepository,
|
||||||
edges []inventory.Edge) mergeReach {
|
edges []inventory.Edge) mergeReach {
|
||||||
from, packaging, already := mergeCandidates(m, entries, read)
|
from, packaging, already := mergeCandidates(m, entries, read)
|
||||||
touched, added, unread := touchedBy(from, entries, m, read)
|
touched, added, unread := touchedBy(from, entries, m)
|
||||||
kept, deleted := splitDeleted(touched, m)
|
kept, deleted := splitDeleted(touched, m)
|
||||||
r := mergeReach{Touched: kept, Deleted: deleted, Packaging: packaging, Already: already, Added: added, Unread: unread}
|
r := mergeReach{Touched: kept, Deleted: deleted, Packaging: packaging, Already: already, Added: added, Unread: unread}
|
||||||
var building []string
|
var building []string
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// A module's act on the operator's warrant, recorded in the hand-act log (novox/hq ADR 0274, ADR 0259 §6).
|
||||||
|
//
|
||||||
|
// mesh-controller hand-act warrant --asker <module> --ask <id>
|
||||||
|
//
|
||||||
|
// The verb `warranted` runs it. A module that asks the operator (an asker) acts on the warrant with its own grants;
|
||||||
|
// the controller's log is where a person's decisions are read back, so the module asks the controller to record
|
||||||
|
// it. **What is recorded is the router's word, never the caller's**: the controller reads the router's own record
|
||||||
|
// of that asker's ask — the bus lets only the router write it — and records who chose, through which channel, with
|
||||||
|
// which proofs, and which answer. The caller gives nothing but which ask: a word of its own, recorded first under
|
||||||
|
// the one id, would stand for every node's (the review of 2026-10-10). Recorded once per
|
||||||
|
// ask, under an id the ask decides, however many of the module's instances ask; an ask still open, ended without
|
||||||
|
// a choice, or another asker's is refused and nothing is written.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/conditions"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
var askerModule = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
|
||||||
|
|
||||||
|
// warrantedID is the one entry an ask's warrant is recorded under.
|
||||||
|
func warrantedID(asker, ask string) string { return "warrant-" + asker + "-" + ask }
|
||||||
|
|
||||||
|
// warrantedAct is the entry for an asker's act on the warrant the router recorded for its ask (state, w), or why
|
||||||
|
// none is written.
|
||||||
|
func warrantedAct(asker, ask, caller, state string, w *asks.Warrant) (link.HandAct, error) {
|
||||||
|
switch {
|
||||||
|
case !askerModule.MatchString(asker):
|
||||||
|
return link.HandAct{}, fmt.Errorf("%q is not a module's name", asker)
|
||||||
|
case asker == askerName:
|
||||||
|
return link.HandAct{}, errors.New("the controller records its own acts on a warrant as it performs them")
|
||||||
|
case !asks.UsableID(ask):
|
||||||
|
return link.HandAct{}, fmt.Errorf("%q is not an ask's id", ask)
|
||||||
|
case state == "" || w == nil:
|
||||||
|
return link.HandAct{}, fmt.Errorf("the router holds no closed record of %s's ask %s", asker, ask)
|
||||||
|
case state == "open":
|
||||||
|
return link.HandAct{}, fmt.Errorf("%s's ask %s is still open: nobody has answered it", asker, ask)
|
||||||
|
case w.Asker != asker || w.Ask != ask:
|
||||||
|
return link.HandAct{}, fmt.Errorf("the router's record is for %s's ask %s", w.Asker, w.Ask)
|
||||||
|
case w.Outcome != asks.OutcomeChosen || w.By == nil:
|
||||||
|
return link.HandAct{}, fmt.Errorf("%s's ask %s ended %s: no person chose, so there is no warrant to record", asker, ask, w.Outcome)
|
||||||
|
case w.AskDigest == "":
|
||||||
|
return link.HandAct{}, fmt.Errorf("the router's warrant for %s's ask %s names no ask digest", asker, ask)
|
||||||
|
}
|
||||||
|
return link.HandAct{ID: warrantedID(asker, ask), Verb: handActWarrant, Args: []string{fmt.Sprintf("the operator chose %s on %s's ask %s", w.Label, asker, ask)},
|
||||||
|
Why: fmt.Sprintf("%s (ask %s of %s)", w.Says(), ask, asker), By: byWords(*w), Cause: conditions.CauseOperatorAnswer,
|
||||||
|
Via: viaWords(*w), Ask: ask, Proofs: w.Proofs, RequestedBy: asker + ", recorded at the word of " + caller,
|
||||||
|
Outcome: "chosen; what " + asker + " did with it is in its own record", At: w.At.UTC()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readRouterRecord reads the router's record of one asker's ask: its state and warrant, or "" when there is none.
|
||||||
|
// The controller's grant reaches the JetStream API whole (`$JS.API.>`), so it reads any asker's record.
|
||||||
|
func readRouterRecord(ctx context.Context, conn *nats.Conn, bucket, asker, ask string) (string, *asks.Warrant, error) {
|
||||||
|
reply, err := conn.RequestWithContext(ctx, "$JS.API.DIRECT.GET.KV_"+bucket+".$KV."+bucket+"."+asker+"."+ask, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
if status := reply.Header.Get("Status"); status != "" {
|
||||||
|
if status == "404" {
|
||||||
|
return "", nil, nil
|
||||||
|
}
|
||||||
|
return "", nil, fmt.Errorf("the router's record could not be read: %s %s", status, reply.Header.Get("Description"))
|
||||||
|
}
|
||||||
|
var rec struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
Warrant *asks.Warrant `json:"warrant"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(reply.Data, &rec); err != nil {
|
||||||
|
return "", nil, fmt.Errorf("the router's record of %s's ask %s cannot be read: %w", asker, ask, err)
|
||||||
|
}
|
||||||
|
return rec.State, rec.Warrant, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handActWarrantCommand(ctx context.Context, args []string) error {
|
||||||
|
set := flag.NewFlagSet("hand-act warrant", flag.ContinueOnError)
|
||||||
|
asker := set.String("asker", "", "the module that asked")
|
||||||
|
ask := set.String("ask", "", "its ask's id")
|
||||||
|
positionals, err := parseAround(set, args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *asker == "" || *ask == "" || len(positionals) > 0 {
|
||||||
|
return errors.New("hand-act warrant --asker <module> --ask <id>: what is recorded is the router's record, and nothing else")
|
||||||
|
}
|
||||||
|
open, err := openStores(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer open.Close()
|
||||||
|
bucket, err := asksRecords(ctx, open.inventory)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if bucket == "" {
|
||||||
|
return errors.New("no module declares the operator channel's records, so no warrant can be read")
|
||||||
|
}
|
||||||
|
return onTheBus(func(conn *nats.Conn) error {
|
||||||
|
state, w, err := readRouterRecord(ctx, conn, bucket, *asker, *ask)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
act, err := warrantedAct(*asker, *ask, link.Caller(), state, w)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w. Nothing was recorded", err)
|
||||||
|
}
|
||||||
|
written, err := link.RecordHandActOnce(ctx, conn, act)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("the warrant could not be recorded: %w", err)
|
||||||
|
}
|
||||||
|
if !written {
|
||||||
|
fmt.Printf("already recorded as %s: %s\n", act.ID, act.Why)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("recorded as %s: %s, through %s\n", act.ID, act.Why, act.Via)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/conditions"
|
||||||
|
)
|
||||||
|
|
||||||
|
func chosenWarrant() *asks.Warrant {
|
||||||
|
return &asks.Warrant{Ask: "instr-1", Asker: "claude-code", Outcome: asks.OutcomeChosen, Option: "approve",
|
||||||
|
Label: "Approve", Level: asks.Approve, Channel: "telegram", Proofs: []string{"P1"},
|
||||||
|
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"},
|
||||||
|
At: time.Date(2026, 10, 10, 4, 0, 0, 0, time.UTC), AskDigest: "sha256:ab"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What is recorded of a module's act on a warrant is the router's word (novox/hq ADR 0274): who chose, how and
|
||||||
|
// with which proofs; the caller gives only what it did. Nothing is recorded without a person's choice.
|
||||||
|
func TestAWarrantIsRecordedFromTheRoutersRecordAlone(t *testing.T) {
|
||||||
|
act, err := warrantedAct("claude-code", "instr-1", "node-tools.shanks", "chosen", chosenWarrant())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if act.ID != "warrant-claude-code-instr-1" || act.Verb != handActWarrant || act.Cause != conditions.CauseOperatorAnswer ||
|
||||||
|
act.By != "the operator, as telegram identity 42" || act.Ask != "instr-1" || !slices.Equal(act.Proofs, []string{"P1"}) ||
|
||||||
|
!strings.Contains(act.Why, "the operator, via telegram (user id verified), chose Approve") ||
|
||||||
|
!strings.Contains(act.RequestedBy, "node-tools.shanks") ||
|
||||||
|
!slices.Equal(act.Args, []string{"the operator chose Approve on claude-code's ask instr-1"}) {
|
||||||
|
t.Fatalf("recorded as %+v", act)
|
||||||
|
}
|
||||||
|
for name, c := range map[string]struct {
|
||||||
|
asker, ask, state string
|
||||||
|
w func() *asks.Warrant
|
||||||
|
}{
|
||||||
|
"no record": {"claude-code", "instr-1", "", func() *asks.Warrant { return nil }},
|
||||||
|
"still open": {"claude-code", "instr-1", "open", chosenWarrant},
|
||||||
|
"another asker's": {"messenger", "instr-1", "chosen", chosenWarrant},
|
||||||
|
"another ask's": {"claude-code", "instr-2", "chosen", chosenWarrant},
|
||||||
|
"the controller's": {"mesh-controller", "instr-1", "chosen", chosenWarrant},
|
||||||
|
"not a module": {"Claude Code", "instr-1", "chosen", chosenWarrant},
|
||||||
|
"expired": {"claude-code", "instr-1", "expired", func() *asks.Warrant {
|
||||||
|
w := chosenWarrant()
|
||||||
|
w.Outcome, w.By = asks.OutcomeExpired, nil
|
||||||
|
return w
|
||||||
|
}},
|
||||||
|
"no digest": {"claude-code", "instr-1", "chosen", func() *asks.Warrant {
|
||||||
|
w := chosenWarrant()
|
||||||
|
w.AskDigest = ""
|
||||||
|
return w
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
if _, err := warrantedAct(c.asker, c.ask, "x", c.state, c.w()); err == nil {
|
||||||
|
t.Errorf("%s: recorded", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheWarrantedVerbRunsTheWarrantLineWithoutAWhy(t *testing.T) {
|
||||||
|
if _, err := argvFor("warranted", map[string]any{"asker": "claude-code", "ask": "instr-1", "what": "a word of the caller's"}); err == nil {
|
||||||
|
t.Error("the caller's own words were taken into the record")
|
||||||
|
}
|
||||||
|
argv, err := argvFor("warranted", map[string]any{"asker": "claude-code", "ask": "instr-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !slices.Equal(argv, []string{"hand-act", "warrant", "--asker", "claude-code", "--ask", "instr-1"}) {
|
||||||
|
t.Fatalf("%v", argv)
|
||||||
|
}
|
||||||
|
if repairingCommand(argv) != "" {
|
||||||
|
t.Error("recording a person's answer is taken for a repair")
|
||||||
|
}
|
||||||
|
if terminalOnly(argv) != nil {
|
||||||
|
t.Error("the verb is kept for the terminal")
|
||||||
|
}
|
||||||
|
if _, err := argvFor("warranted", map[string]any{"asker": "claude-code"}); err == nil {
|
||||||
|
t.Error("a call naming no ask was taken")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,12 +19,10 @@ func TestTheBuildSeatsHolderFollowsTheControllerThatDefinesItsWorker(t *testing.
|
|||||||
{From: "route-proxy", To: "mesh-controller", Kind: inventory.EdgePackages},
|
{From: "route-proxy", To: "mesh-controller", Kind: inventory.EdgePackages},
|
||||||
{From: "route-proxy", To: "build-agent", Kind: inventory.EdgeBuiltBy},
|
{From: "route-proxy", To: "build-agent", Kind: inventory.EdgeBuiltBy},
|
||||||
}
|
}
|
||||||
// A packages edge recorded before novox/hq ADR 0267 widens nothing: the controller moved alone moves
|
set := reachableFrom([]string{"mesh-controller"}, edges)
|
||||||
// alone, and a change to a package all three build from moves all three, each by its own build source.
|
if len(set) != 3 {
|
||||||
if alone := reachableFrom([]string{"mesh-controller"}, edges); len(alone) != 1 {
|
t.Fatalf("the controller, what packages it, and nothing more: %v", set)
|
||||||
t.Fatalf("the controller alone, whatever packages its repository: %v", alone)
|
|
||||||
}
|
}
|
||||||
set := reachableFrom([]string{"mesh-controller", "build-agent", "route-proxy"}, edges)
|
|
||||||
tiers := tiersOf(set, edges)
|
tiers := tiersOf(set, edges)
|
||||||
pos := map[string]int{}
|
pos := map[string]int{}
|
||||||
for i, tier := range tiers {
|
for i, tier := range tiers {
|
||||||
|
|||||||
@@ -1,202 +0,0 @@
|
|||||||
package builder
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// What a build says it was made from, as files (novox/hq ADR 0267), and what a build compiling a Go
|
|
||||||
// program is handed.
|
|
||||||
|
|
||||||
// onTrunk answers the trunk's questions as a clone of a commit on main would, or off it.
|
|
||||||
type onTrunk struct {
|
|
||||||
*recorded
|
|
||||||
off bool
|
|
||||||
// behind is a commit on the trunk that is not its head: an older commit built by hand.
|
|
||||||
behind bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o onTrunk) run(ctx context.Context, dir, name string, args ...string) (string, error) {
|
|
||||||
if name == "git" && len(args) > 0 && args[0] == "symbolic-ref" {
|
|
||||||
return "origin/main\n", nil
|
|
||||||
}
|
|
||||||
if name == "git" && len(args) > 1 && args[0] == "rev-parse" && args[1] == "origin/main" && o.behind {
|
|
||||||
return "feedfacefeedfacefeedfacefeedfacefeedface\n", nil
|
|
||||||
}
|
|
||||||
if name == "git" && len(args) > 0 && args[0] == "merge-base" && o.off {
|
|
||||||
return "", os.ErrNotExist
|
|
||||||
}
|
|
||||||
return compiling{o.recorded}.run(ctx, dir, name, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// aSharedRepository is a repository holding two programs that share a package, as the controller's does.
|
|
||||||
func aSharedRepository(readme, shared string) map[string]string {
|
|
||||||
return map[string]string{
|
|
||||||
"go.mod": "module example.com/ctl\n\ngo 1.22\n",
|
|
||||||
"go.sum": "",
|
|
||||||
"README.md": readme,
|
|
||||||
"cmd/ctl/main.go": "package main\n\nimport _ \"example.com/ctl/internal/shared\"\n\nfunc main() {}\n",
|
|
||||||
"proxy/main.go": "package main\n\nimport _ \"example.com/ctl/internal/shared\"\n\nfunc main() {}\n",
|
|
||||||
"internal/shared/s.go": "package shared\n\nconst S = " + shared + "\n",
|
|
||||||
"internal/only/o.go": "package only\n",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const aProxy = `{"module":"route-proxy","version":"1",
|
|
||||||
"build":{"artifacts":[
|
|
||||||
{"name":"server","kind":"image","from":"Dockerfile","compiles":"proxy",
|
|
||||||
"context":{"repository":"https://forge.invalid/ctl.git","ref":"main"}},
|
|
||||||
{"name":"trust","kind":"upstream","from":"alpine@sha256:` + "3333333333333333333333333333333333333333333333333333333333333333" + `"}]}}`
|
|
||||||
|
|
||||||
func buildTheProxy(t *testing.T, context_ map[string]string, off bool, behind ...bool) (Result, *recorded, string) {
|
|
||||||
t.Helper()
|
|
||||||
r := &recorded{
|
|
||||||
contents: map[string]string{"modules/route-proxy/" + ManifestName: aProxy, "modules/route-proxy/Dockerfile": "FROM scratch\nCOPY . .\n", "modules/route-proxy/README.md": "x"},
|
|
||||||
secondary: map[string]map[string]string{"https://forge.invalid/ctl.git": context_},
|
|
||||||
}
|
|
||||||
workspace := t.TempDir()
|
|
||||||
got, err := Build(context.Background(), onTrunk{r, off, len(behind) > 0 && behind[0]}.run, r,
|
|
||||||
"https://forge.invalid/catalogue.git", "modules/route-proxy", "", workspace, nil, Npmrc{}, GitCredential{}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return got, r, workspace
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnImageCompilingGoIsHandedItsBuildSourceAndSaysIt(t *testing.T) {
|
|
||||||
got, r, workspace := buildTheProxy(t, aSharedRepository("one", "1"), false)
|
|
||||||
|
|
||||||
// Built in the narrowed tree, which holds the program's closure and nothing else.
|
|
||||||
at := ""
|
|
||||||
for i, line := range r.ran {
|
|
||||||
if strings.HasPrefix(line, "docker build ") {
|
|
||||||
at = r.dirs[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if filepath.Base(at) != "narrow-server" {
|
|
||||||
t.Fatalf("docker build ran in %q, not the narrowed build source", at)
|
|
||||||
}
|
|
||||||
for file, want := range map[string]bool{"proxy/main.go": true, "internal/shared/s.go": true, "go.mod": true,
|
|
||||||
"cmd/ctl/main.go": false, "internal/only/o.go": false, "README.md": false} {
|
|
||||||
_, err := os.Stat(filepath.Join(workspace, "narrow-server", filepath.FromSlash(file)))
|
|
||||||
if (err == nil) != want {
|
|
||||||
t.Errorf("%s handed to the recipe: %v, wanted %v", file, err == nil, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Said per repository: its own, the manifest and the recipe; the context's, the closure.
|
|
||||||
if len(got.Sources) != 2 {
|
|
||||||
t.Fatalf("sources %+v", got.Sources)
|
|
||||||
}
|
|
||||||
own, ctx := got.Sources[0], got.Sources[1]
|
|
||||||
if own.Repository != "" || !slices.Equal(own.Paths, []string{"modules/route-proxy/Dockerfile", "modules/route-proxy/module.json"}) {
|
|
||||||
t.Errorf("its own build source: %+v", own)
|
|
||||||
}
|
|
||||||
if ctx.Repository != "https://forge.invalid/ctl.git" || ctx.Ref != "main" {
|
|
||||||
t.Errorf("the context's build source names %q at %q", ctx.Repository, ctx.Ref)
|
|
||||||
}
|
|
||||||
for file, want := range map[string]bool{"proxy/main.go": true, "internal/shared/s.go": true, "go.mod": true,
|
|
||||||
"cmd/ctl/main.go": false, "README.md": false} {
|
|
||||||
if SourceHolds(ctx.Paths, file) != want {
|
|
||||||
t.Errorf("the context's build source holds %s: %v, wanted %v (%v)", file, !want, want, ctx.Paths)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **The fingerprint is over the build source** (rule 5): a change outside it is one build, inside it another.
|
|
||||||
readme, _, _ := buildTheProxy(t, aSharedRepository("two", "1"), false)
|
|
||||||
if readme.Source != got.Source {
|
|
||||||
t.Errorf("a README of the context changed the fingerprint: %s %s", got.Source, readme.Source)
|
|
||||||
}
|
|
||||||
shared, _, _ := buildTheProxy(t, aSharedRepository("one", "2"), false)
|
|
||||||
if shared.Source == got.Source {
|
|
||||||
t.Error("a change to the program's closure kept its fingerprint")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A build off the trunk, or of a trunk commit that is not its head, says no build source: the planner maps
|
|
||||||
// a merge onto the trunk head's, and an older commit's closure lacks what was imported since.
|
|
||||||
func TestABuildOffTheTrunksHeadSaysNoBuildSource(t *testing.T) {
|
|
||||||
got, _, _ := buildTheProxy(t, aSharedRepository("one", "1"), true)
|
|
||||||
if len(got.Sources) != 0 {
|
|
||||||
t.Fatalf("a build off the trunk said %+v", got.Sources)
|
|
||||||
}
|
|
||||||
got, _, _ = buildTheProxy(t, aSharedRepository("one", "1"), false, true)
|
|
||||||
if len(got.Sources) != 0 {
|
|
||||||
t.Fatalf("a build of an older trunk commit said %+v", got.Sources)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// An archive of the module's whole directory holds every file of it.
|
|
||||||
func TestAnArchiveOfTheWholeDirectoryHoldsIt(t *testing.T) {
|
|
||||||
manifest := `{"module":"look","version":"1","build":{"artifacts":[{"name":"all","kind":"archive","from":"."}]},
|
|
||||||
"resources":[{"id":"files","type":"archive","path":"/opt/look","artifact":"all"}]}`
|
|
||||||
r := &recorded{contents: map[string]string{"modules/look/" + ManifestName: manifest, "modules/look/a/b.css": "x"}}
|
|
||||||
got, err := Build(context.Background(), onTrunk{recorded: r}.run, r,
|
|
||||||
"https://forge.invalid/catalogue.git", "modules/look", "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(got.Sources) != 1 || !SourceHolds(got.Sources[0].Paths, "modules/look/a/b.css") ||
|
|
||||||
SourceHolds(got.Sources[0].Paths, "modules/other/x") {
|
|
||||||
t.Fatalf("sources %+v", got.Sources)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A recipe reading past its build source fails, naming what it could not find — in the real docker build;
|
|
||||||
// here, the file is simply not in the tree it is handed, which is what makes that so.
|
|
||||||
func TestAnImageCompilingANonexistentPackageFails(t *testing.T) {
|
|
||||||
r := &recorded{
|
|
||||||
contents: map[string]string{ManifestName: strings.Replace(aProxy, `"compiles":"proxy"`, `"compiles":"nowhere"`, 1),
|
|
||||||
"Dockerfile": "FROM scratch\n"},
|
|
||||||
secondary: map[string]map[string]string{"https://forge.invalid/ctl.git": aSharedRepository("one", "1")},
|
|
||||||
}
|
|
||||||
_, err := Build(context.Background(), onTrunk{recorded: r}.run, r,
|
|
||||||
"https://forge.invalid/catalogue.git", "", "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "nowhere") {
|
|
||||||
t.Fatalf("a package that is not there built: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A Go bundle of a module built from its repository's root says its import closure, and the manifest;
|
|
||||||
// an image that compiles nothing it was told of leaves the module's source whole, and says none.
|
|
||||||
func TestAGoBundleSaysItsClosureAndAnUntoldImageNothing(t *testing.T) {
|
|
||||||
files := aSharedRepository("one", "1")
|
|
||||||
manifest := `{"module":"ctl","version":"1","build":{"artifacts":[
|
|
||||||
{"name":"controller","kind":"bundle","language":"go","system":"arch","from":"cmd/ctl","binary":"ctl"}]},
|
|
||||||
"resources":[{"id":"controller","type":"process","name":"ctl","artifact":"controller","run":["./ctl"]}]}`
|
|
||||||
r := &recorded{contents: map[string]string{ManifestName: manifest}}
|
|
||||||
for k, v := range files {
|
|
||||||
r.contents[k] = v
|
|
||||||
}
|
|
||||||
held := map[string]string{"mesh-tools-go/build": "registry.invalid/mesh-tools-go/build@sha256:" + strings.Repeat("b", 64)}
|
|
||||||
got, err := Build(context.Background(), onTrunk{recorded: r}.run, r,
|
|
||||||
"https://forge.invalid/ctl.git", "", "", t.TempDir(), held, Npmrc{}, GitCredential{}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(got.Sources) != 1 || got.Sources[0].Repository != "" {
|
|
||||||
t.Fatalf("sources %+v", got.Sources)
|
|
||||||
}
|
|
||||||
for file, want := range map[string]bool{"cmd/ctl/main.go": true, "internal/shared/s.go": true, ManifestName: true,
|
|
||||||
"go.sum": true, "proxy/main.go": false, "README.md": false, "internal/only/o.go": false} {
|
|
||||||
if SourceHolds(got.Sources[0].Paths, file) != want {
|
|
||||||
t.Errorf("%s: held %v, wanted %v (%v)", file, !want, want, got.Sources[0].Paths)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
untold := `{"module":"ctl","version":"1","build":{"artifacts":[
|
|
||||||
{"name":"server","kind":"image","from":"Dockerfile"}]}}`
|
|
||||||
r = &recorded{contents: map[string]string{ManifestName: untold, "Dockerfile": "FROM scratch\n"}}
|
|
||||||
got, err = Build(context.Background(), onTrunk{recorded: r}.run, r,
|
|
||||||
"https://forge.invalid/ctl.git", "", "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(got.Sources) != 0 {
|
|
||||||
t.Fatalf("an image compiling nothing it was told of said a build source: %+v", got.Sources)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+5
-113
@@ -11,7 +11,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -88,23 +87,6 @@ type Result struct {
|
|||||||
// pin the build. Two builds with one fingerprint are one build, whatever digests they made
|
// pin the build. Two builds with one fingerprint are one build, whatever digests they made
|
||||||
// (novox/hq issue 280).
|
// (novox/hq issue 280).
|
||||||
Source string
|
Source string
|
||||||
|
|
||||||
// Sources are what this build was made from, as files (novox/hq ADR 0267 rule 1): per repository, the
|
|
||||||
// entries a changed file is tested against (SourceHolds). The module's own repository has an empty
|
|
||||||
// Repository. Said only for a build of a commit on the trunk, and only for a repository whose every
|
|
||||||
// artifact's build source is known — a Go program's import closure, an archive's directory, an image's
|
|
||||||
// recipe and the package it compiles; for any other, nothing is said and the whole of what the build
|
|
||||||
// sees stays its source, as before.
|
|
||||||
Sources []BuildSource
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildSource is the build source a build read in one repository (novox/hq ADR 0267).
|
|
||||||
type BuildSource struct {
|
|
||||||
// Repository and Ref are a context's, as the manifest names it; empty for the module's own.
|
|
||||||
Repository string
|
|
||||||
Ref string
|
|
||||||
// Paths are the entries, relative to the repository's root (SourceHolds).
|
|
||||||
Paths []string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GitCredential is the forge credential a clone may present when the server asks for one.
|
// GitCredential is the forge credential a clone may present when the server asks for one.
|
||||||
@@ -224,10 +206,6 @@ func build(ctx context.Context, run Runner, publish Publisher,
|
|||||||
|
|
||||||
// What it is made from, for its source fingerprint: the module's own tree first.
|
// What it is made from, for its source fingerprint: the module's own tree first.
|
||||||
src := newSourceInputs(manifest.Module)
|
src := newSourceInputs(manifest.Module)
|
||||||
src.prefix = strings.Trim(filepath.ToSlash(filepath.Clean(path)), "/")
|
|
||||||
if src.prefix == "." {
|
|
||||||
src.prefix = ""
|
|
||||||
}
|
|
||||||
if src.tree, err = gitTree(ctx, run, tree, path); err != nil {
|
if src.tree, err = gitTree(ctx, run, tree, path); err != nil {
|
||||||
src.notPinned("its tree could not be named: " + err.Error())
|
src.notPinned("its tree could not be named: " + err.Error())
|
||||||
}
|
}
|
||||||
@@ -320,23 +298,9 @@ func build(ctx context.Context, run Runner, publish Publisher,
|
|||||||
if fingerprint == "" {
|
if fingerprint == "" {
|
||||||
say("source", "no source fingerprint: %s", orNoTree(src.unpinned))
|
say("source", "no source fingerprint: %s", orNoTree(src.unpinned))
|
||||||
}
|
}
|
||||||
// **Only a build of the trunk's head says its build source** (novox/hq ADR 0267): the planner maps the
|
|
||||||
// next merge onto the build source of the newest build, and a branch's closure — or an older trunk
|
|
||||||
// commit's, built by hand — is not the trunk's. Nor does a build whose context was not its trunk's head.
|
|
||||||
var sources []BuildSource
|
|
||||||
if trunk != "" && onTrunk && src.contextsAtHead && atTrunkHead(ctx, run, tree, commit, trunk) {
|
|
||||||
sources = src.buildSources()
|
|
||||||
for _, s := range sources {
|
|
||||||
where := "its own repository"
|
|
||||||
if s.Repository != "" {
|
|
||||||
where = s.Repository
|
|
||||||
}
|
|
||||||
say("source", "%d path(s) of %s", len(s.Paths), where)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Result{Manifest: resolved, Commit: commit, Built: built,
|
return Result{Manifest: resolved, Commit: commit, Built: built,
|
||||||
Against: against(within, manifest, stoodOn), Read: readBy(manifest), Source: fingerprint,
|
Against: against(within, manifest, stoodOn), Read: readBy(manifest), Source: fingerprint,
|
||||||
Trunk: trunk, OnTrunk: onTrunk, Branches: branches, Sources: sources}, nil
|
Trunk: trunk, OnTrunk: onTrunk, Branches: branches}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// branchesHolding is every branch of a fresh clone's origin the commit is on, without `origin/`.
|
// branchesHolding is every branch of a fresh clone's origin the commit is on, without `origin/`.
|
||||||
@@ -381,28 +345,6 @@ func trunkOf(ctx context.Context, run Runner, clone, commit string) (string, boo
|
|||||||
return trunk, err == nil
|
return trunk, err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// atTrunkHead is whether a clone's commit is its trunk's head as the clone holds it.
|
|
||||||
func atTrunkHead(ctx context.Context, run Runner, clone, commit, trunk string) bool {
|
|
||||||
head, err := run(ctx, clone, "git", "rev-parse", "origin/"+trunk)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
at, err := run(ctx, clone, "git", "rev-parse", commit)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(head) != "" && strings.TrimSpace(head) == strings.TrimSpace(at)
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanEntry is a path of the module's directory as an entry: cleaned, relative, `.` for the directory.
|
|
||||||
func cleanEntry(p string) string {
|
|
||||||
c := strings.Trim(path.Clean("/"+filepath.ToSlash(p)), "/")
|
|
||||||
if c == "" {
|
|
||||||
return "."
|
|
||||||
}
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// orNoTree is why a build has no source fingerprint, for its log.
|
// orNoTree is why a build has no source fingerprint, for its log.
|
||||||
func orNoTree(why string) string {
|
func orNoTree(why string) string {
|
||||||
if why == "" {
|
if why == "" {
|
||||||
@@ -706,51 +648,15 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
|||||||
} else if src != nil {
|
} else if src != nil {
|
||||||
src.contexts[a.Name] = t
|
src.contexts[a.Name] = t
|
||||||
}
|
}
|
||||||
buildDir = cloned
|
// docker build accepts -f outside the context it is given; the recipe stays exactly
|
||||||
if t, _ := trunkOf(ctx, run, cloned, "HEAD"); t == "" || !atTrunkHead(ctx, run, cloned, "HEAD", t) {
|
// where it was read from and validated against, absolute so the working directory
|
||||||
src.contextOffHead()
|
// switching to the cloned context does not change which file that is.
|
||||||
}
|
|
||||||
}
|
|
||||||
// docker build accepts -f outside the context it is given; the recipe stays exactly where it was
|
|
||||||
// read from and validated against, absolute so a context elsewhere does not change which file
|
|
||||||
// that is.
|
|
||||||
if buildDir != tree || a.Compiles != "" {
|
|
||||||
absRecipe, err := filepath.Abs(filepath.Join(tree, a.From))
|
absRecipe, err := filepath.Abs(filepath.Join(tree, a.From))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.Built{}, fmt.Errorf("%s: %s's recipe: %w", module, a.Name, err)
|
return catalogue.Built{}, fmt.Errorf("%s: %s's recipe: %w", module, a.Name, err)
|
||||||
}
|
}
|
||||||
recipePath = absRecipe
|
recipePath = absRecipe
|
||||||
}
|
buildDir = cloned
|
||||||
// **An image that compiles a Go program is handed its build source and nothing else** (novox/hq
|
|
||||||
// ADR 0267 rules 1 and 3): the program's import closure, read from the context it is built in, so a
|
|
||||||
// merge elsewhere in that repository is no change to it — and a recipe that copies a file outside
|
|
||||||
// it fails here, naming the file, rather than building from something no merge is mapped onto.
|
|
||||||
if a.Compiles != "" {
|
|
||||||
paths, err := GoBuildSource(buildDir, a.Compiles)
|
|
||||||
if err != nil {
|
|
||||||
return catalogue.Built{}, fmt.Errorf("%s: %s compiles %s, whose build source cannot be read: %w",
|
|
||||||
module, a.Name, a.Compiles, err)
|
|
||||||
}
|
|
||||||
narrowed := filepath.Join(workspace, "narrow-"+a.Name)
|
|
||||||
sum, err := narrowTree(buildDir, narrowed, paths)
|
|
||||||
if err != nil {
|
|
||||||
return catalogue.Built{}, fmt.Errorf("%s: handing %s its build source: %w", module, a.Name, err)
|
|
||||||
}
|
|
||||||
say("image", "%s is handed its build source: %d path(s) of %s", a.Name, len(paths), a.Compiles)
|
|
||||||
if a.Context != nil {
|
|
||||||
src.contexts[a.Name] = sum
|
|
||||||
src.readIn(*a.Context, paths)
|
|
||||||
} else {
|
|
||||||
src.ownHas(paths...)
|
|
||||||
}
|
|
||||||
buildDir = narrowed
|
|
||||||
} else if a.Context != nil {
|
|
||||||
src.readWhole(*a.Context)
|
|
||||||
}
|
|
||||||
if a.Compiles != "" || a.Context != nil {
|
|
||||||
src.ownHas(cleanEntry(a.From))
|
|
||||||
} else {
|
|
||||||
src.ownWhole()
|
|
||||||
}
|
}
|
||||||
invocation := append([]string{"build", "-f", recipePath, "-t", local}, args...)
|
invocation := append([]string{"build", "-f", recipePath, "-t", local}, args...)
|
||||||
if a.Target != "" {
|
if a.Target != "" {
|
||||||
@@ -802,18 +708,6 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
|||||||
if src != nil {
|
if src != nil {
|
||||||
src.toolchains[a.Name] = toolchainOf(chain, base)
|
src.toolchains[a.Name] = toolchainOf(chain, base)
|
||||||
}
|
}
|
||||||
// A Go program's build source is its import closure (novox/hq ADR 0267 rule 1). Not read, it is the
|
|
||||||
// module's whole directory, as before — said, so the wider plan has a reason a person can find.
|
|
||||||
if chain.Language == "go" {
|
|
||||||
if paths, err := GoBuildSource(tree, a.From); err != nil {
|
|
||||||
say("bundle", "%s's build source is its whole directory: %v", a.Name, err)
|
|
||||||
src.ownWhole()
|
|
||||||
} else {
|
|
||||||
src.ownHas(paths...)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
src.ownWhole()
|
|
||||||
}
|
|
||||||
if chain.Language == "typescript" {
|
if chain.Language == "typescript" {
|
||||||
if own, _ := ownDependencies(tree); len(own) > 0 {
|
if own, _ := ownDependencies(tree); len(own) > 0 {
|
||||||
src.notPinned(a.Name + " resolves packages of its own at build time")
|
src.notPinned(a.Name + " resolves packages of its own at build time")
|
||||||
@@ -860,7 +754,6 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
|||||||
// there is no Publisher call — the container itself publishes, with the credential the
|
// there is no Publisher call — the container itself publishes, with the credential the
|
||||||
// build was handed.
|
// build was handed.
|
||||||
say("package", "building and publishing %s (%s)", a.Name, a.Language)
|
say("package", "building and publishing %s (%s)", a.Name, a.Language)
|
||||||
src.ownWhole()
|
|
||||||
src.notPinned(a.Name + " is a package, built from what the registry holds when it is built")
|
src.notPinned(a.Name + " is a package, built from what the registry holds when it is built")
|
||||||
reference, err := publishPackage(ctx, run, module, tree, a, npmrc, say)
|
reference, err := publishPackage(ctx, run, module, tree, a, npmrc, say)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -870,7 +763,6 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
|||||||
return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: reference}, nil
|
return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: reference}, nil
|
||||||
|
|
||||||
case catalogue.ArtifactArchive:
|
case catalogue.ArtifactArchive:
|
||||||
src.ownHas(cleanEntry(a.From) + "/**")
|
|
||||||
body, err := pack(filepath.Join(tree, a.From))
|
body, err := pack(filepath.Join(tree, a.From))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.Built{}, fmt.Errorf("%s: packing %s failed: %w", module, a.Name, err)
|
return catalogue.Built{}, fmt.Errorf("%s: packing %s failed: %w", module, a.Name, err)
|
||||||
|
|||||||
@@ -543,11 +543,6 @@ func passedSoFar(ran []string) string {
|
|||||||
return strings.Join(ran, ", ") + " passed; "
|
return strings.Join(ran, ", ") + " passed; "
|
||||||
}
|
}
|
||||||
|
|
||||||
// SomeManifestsEnv tells the judge's module check that the manifests it is given are only those a change touches,
|
|
||||||
// so a seat another module of the repository declares is a note there, not a refusal (novox/hq issue 364). A judge
|
|
||||||
// that predates it reads nothing of it and refuses as before.
|
|
||||||
const SomeManifestsEnv = "MESH_MODULE_CHECK_SOME=1"
|
|
||||||
|
|
||||||
// gateLayer runs the gate: the touched manifests through `module check`, every machine composed with the
|
// gateLayer runs the gate: the touched manifests through `module check`, every machine composed with the
|
||||||
// change, and the replays of what the mesh runs. It answers the gate's verdict and summary.
|
// change, and the replays of what the mesh runs. It answers the gate's verdict and summary.
|
||||||
func gateLayer(ctx context.Context, spec CheckSpec, tree, root, gate, verdictFile string, env []string,
|
func gateLayer(ctx context.Context, spec CheckSpec, tree, root, gate, verdictFile string, env []string,
|
||||||
@@ -567,7 +562,7 @@ func gateLayer(ctx context.Context, spec CheckSpec, tree, root, gate, verdictFil
|
|||||||
checked := func(dir string) (string, error) {
|
checked := func(dir string) (string, error) {
|
||||||
var own tail
|
var own tail
|
||||||
cmd := exec.CommandContext(ctx, "docker", LabelledArgs("docker",
|
cmd := exec.CommandContext(ctx, "docker", LabelledArgs("docker",
|
||||||
inToolchain(dir, append(append([]string{}, env...), SomeManifestsEnv), append([]string{gate, "module", "check"}, manifests...)...), spec.ID)...)
|
inToolchain(dir, env, append([]string{gate, "module", "check"}, manifests...)...), spec.ID)...)
|
||||||
inItsOwnGroup(cmd)
|
inItsOwnGroup(cmd)
|
||||||
w := io.MultiWriter(out, &own)
|
w := io.MultiWriter(out, &own)
|
||||||
cmd.Stdout, cmd.Stderr = w, w
|
cmd.Stdout, cmd.Stderr = w, w
|
||||||
|
|||||||
@@ -1,528 +0,0 @@
|
|||||||
package builder
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"go/parser"
|
|
||||||
"go/token"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A Go program's build source (novox/hq ADR 0267 rule 1): the files it is built from, derived from its
|
|
||||||
// import closure rather than listed by hand, because a list drifts from the imports it describes and a
|
|
||||||
// path missing from it is a real change missed — worse than a needless rebuild.
|
|
||||||
//
|
|
||||||
// **The closure read here is never narrower than `go list -deps`.** Every .go file of a package that is
|
|
||||||
// not a test is read, whatever its build constraint, so the imports are the union over every system and
|
|
||||||
// tag; a directory is held whole (but for its tests), so a file added to a package is in it; an embed is
|
|
||||||
// held by the directory its pattern starts in, everything below it. Read with the standard library's
|
|
||||||
// parser and no toolchain: the build machine carries none, and a closure that needed the network to
|
|
||||||
// read would be one a build could not say offline.
|
|
||||||
//
|
|
||||||
// A build source is a list of entries, relative to the root the build sees:
|
|
||||||
//
|
|
||||||
// dir/ a Go package's directory: every file directly in it but its tests (`*_test.go`)
|
|
||||||
// dir/** everything below a directory (an embed)
|
|
||||||
// ** the whole tree
|
|
||||||
// file one file
|
|
||||||
//
|
|
||||||
// The root package's directory is `./`.
|
|
||||||
|
|
||||||
// GoPackageDirEntry is the entry for a Go package's directory.
|
|
||||||
func goPackageDirEntry(dir string) string {
|
|
||||||
if dir == "" || dir == "." {
|
|
||||||
return "./"
|
|
||||||
}
|
|
||||||
return dir + "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
// SourceHolds is whether a changed file — a path relative to the root the build source was read in — is
|
|
||||||
// in that build source.
|
|
||||||
func SourceHolds(entries []string, file string) bool {
|
|
||||||
file = strings.TrimPrefix(path.Clean("/"+strings.TrimSpace(file)), "/")
|
|
||||||
for _, e := range entries {
|
|
||||||
switch {
|
|
||||||
case e == "**":
|
|
||||||
return true
|
|
||||||
case strings.HasSuffix(e, "/**"):
|
|
||||||
dir := strings.TrimSuffix(e, "/**")
|
|
||||||
if dir == "." || dir == "" || file == dir || strings.HasPrefix(file, dir+"/") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
case strings.HasSuffix(e, "/"):
|
|
||||||
dir := strings.TrimSuffix(e, "/")
|
|
||||||
parent := path.Dir(file)
|
|
||||||
if (dir == "." && parent == ".") || parent == dir {
|
|
||||||
if !strings.HasSuffix(file, "_test.go") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case e == file:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// GoBuildSource is the build source of the Go program whose main package is pkg, a directory relative to
|
|
||||||
// root: the directories of every package of its import closure inside root, the embeds those packages
|
|
||||||
// name, its module's go.mod, go.sum and vendor/modules.txt, and a go.work wherever one would be read. An
|
|
||||||
// entry for a file that does not exist is kept: creating it is a change to the build.
|
|
||||||
//
|
|
||||||
// Refused — so the build source is not narrowed, and nothing is missed — when the closure cannot be told
|
|
||||||
// from the files: no go.mod holds the package, a go.work is present, a local replace leaves root, a file
|
|
||||||
// does not parse, or a cgo preamble reaches outside its directory.
|
|
||||||
func GoBuildSource(root, pkg string) ([]string, error) {
|
|
||||||
root, err := filepath.Abs(root)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
rel := path.Clean(strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(pkg)), "/"))
|
|
||||||
if rel == ".." || strings.HasPrefix(rel, "../") {
|
|
||||||
return nil, fmt.Errorf("the package %q leaves the tree it is built from", pkg)
|
|
||||||
}
|
|
||||||
if info, err := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); err != nil || !info.IsDir() {
|
|
||||||
return nil, fmt.Errorf("%q is not a directory of the tree it is built from", pkg)
|
|
||||||
}
|
|
||||||
// The module holding the package: the nearest go.mod at or above it, within root.
|
|
||||||
modRoot := ""
|
|
||||||
for dir := rel; ; dir = path.Dir(dir) {
|
|
||||||
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(dir), "go.mod")); err == nil {
|
|
||||||
modRoot = dir
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if dir == "." {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if modRoot == "" {
|
|
||||||
return nil, fmt.Errorf("no go.mod holds %q within the tree it is built from", pkg)
|
|
||||||
}
|
|
||||||
entries := map[string]bool{}
|
|
||||||
file := func(dir, name string) {
|
|
||||||
entries[strings.TrimPrefix(path.Join(dir, name), "./")] = true
|
|
||||||
}
|
|
||||||
file(modRoot, "go.mod")
|
|
||||||
file(modRoot, "go.sum")
|
|
||||||
file(modRoot, "vendor/modules.txt")
|
|
||||||
// A workspace changes how every import resolves; one present is not read past, one created later is a
|
|
||||||
// change to the build.
|
|
||||||
for dir := modRoot; ; dir = path.Dir(dir) {
|
|
||||||
file(dir, "go.work")
|
|
||||||
file(dir, "go.work.sum")
|
|
||||||
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(dir), "go.work")); err == nil {
|
|
||||||
return nil, fmt.Errorf("%s holds a go.work, and a workspace's imports are not read here", path.Join(dir, "go.work"))
|
|
||||||
}
|
|
||||||
if dir == "." {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
modPath, replaces, err := readGoMod(filepath.Join(root, filepath.FromSlash(modRoot), "go.mod"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
vendored := false
|
|
||||||
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(modRoot), "vendor", "modules.txt")); err == nil {
|
|
||||||
vendored = true
|
|
||||||
}
|
|
||||||
// resolve is the directories, relative to root, an import may be read from: none for the standard
|
|
||||||
// library and for a module outside the tree, which go.mod and go.sum pin. A vendored module replaced
|
|
||||||
// by a local directory is both — vendor/ under -mod=vendor, the directory under -mod=mod — and both
|
|
||||||
// are held, so neither way of building it is missed.
|
|
||||||
resolve := func(importPath string) ([]string, error) {
|
|
||||||
within := func(prefix, dir string) (string, bool) {
|
|
||||||
if importPath == prefix {
|
|
||||||
return dir, true
|
|
||||||
}
|
|
||||||
if rest, ok := strings.CutPrefix(importPath, prefix+"/"); ok {
|
|
||||||
return path.Join(dir, rest), true
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
if dir, ok := within(modPath, modRoot); ok {
|
|
||||||
return []string{dir}, nil
|
|
||||||
}
|
|
||||||
var dirs []string
|
|
||||||
for _, r := range replaces {
|
|
||||||
if sub, ok := within(r.from, ""); ok {
|
|
||||||
target := path.Clean(path.Join(modRoot, r.to))
|
|
||||||
if target == ".." || strings.HasPrefix(target, "../") {
|
|
||||||
return nil, fmt.Errorf("go.mod replaces %s with %s, outside the tree it is built from", r.from, r.to)
|
|
||||||
}
|
|
||||||
dirs = append(dirs, path.Join(target, sub))
|
|
||||||
// Its go.mod states what it requires, read when it is built from there.
|
|
||||||
entries[path.Join(target, "go.mod")] = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
first, _, _ := strings.Cut(importPath, "/")
|
|
||||||
if len(dirs) == 0 && !strings.Contains(first, ".") {
|
|
||||||
return nil, nil // the standard library
|
|
||||||
}
|
|
||||||
if vendored {
|
|
||||||
dirs = append(dirs, path.Join(modRoot, "vendor", importPath))
|
|
||||||
}
|
|
||||||
return dirs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
seen := map[string]bool{}
|
|
||||||
queue := []string{rel}
|
|
||||||
for len(queue) > 0 {
|
|
||||||
dir := queue[0]
|
|
||||||
queue = queue[1:]
|
|
||||||
if seen[dir] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[dir] = true
|
|
||||||
entries[goPackageDirEntry(dir)] = true
|
|
||||||
// A go.mod made between the module's root and a package moves that package out of the module.
|
|
||||||
for up := dir; up != modRoot && up != "." && up != "/" && !strings.HasPrefix(up, "../"); up = path.Dir(up) {
|
|
||||||
file(up, "go.mod")
|
|
||||||
}
|
|
||||||
listing, err := os.ReadDir(filepath.Join(root, filepath.FromSlash(dir)))
|
|
||||||
if err != nil {
|
|
||||||
// A package that is not there fails the build that imports it; its directory is held, so
|
|
||||||
// adding it is a change.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, f := range listing {
|
|
||||||
name := f.Name()
|
|
||||||
// **C and assembly beside Go** may include files from below the package's directory: the
|
|
||||||
// directory is held whole, and an include reaching above it is refused.
|
|
||||||
if !f.IsDir() && nativeSource(name) {
|
|
||||||
entries[strings.TrimPrefix(dir+"/**", "./")] = true
|
|
||||||
if dir == "." {
|
|
||||||
entries["**"] = true
|
|
||||||
}
|
|
||||||
if err := includesStayWithin(filepath.Join(root, filepath.FromSlash(dir), name)); err != nil {
|
|
||||||
return nil, fmt.Errorf("%s: %w", path.Join(dir, name), err)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if f.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
full := filepath.Join(root, filepath.FromSlash(dir), name)
|
|
||||||
imports, embeds, err := goFileReads(full)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s: %w", path.Join(dir, name), err)
|
|
||||||
}
|
|
||||||
for _, ip := range imports {
|
|
||||||
targets, err := resolve(ip)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, target := range targets {
|
|
||||||
if !seen[target] {
|
|
||||||
queue = append(queue, target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, e := range embeds {
|
|
||||||
entries[path.Join(dir, e)+"/**"] = true
|
|
||||||
if !strings.ContainsAny(e, "*?[\\") {
|
|
||||||
entries[path.Join(dir, e)] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out := make([]string, 0, len(entries))
|
|
||||||
for e := range entries {
|
|
||||||
out = append(out, e)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// goReplace is one local replacement in go.mod: an import path read from a directory.
|
|
||||||
type goReplace struct{ from, to string }
|
|
||||||
|
|
||||||
// readGoMod is a go.mod's module path and its replacements by a local directory. A replacement by another
|
|
||||||
// module version is resolved by go.sum, which the build source holds.
|
|
||||||
func readGoMod(file string) (string, []goReplace, error) {
|
|
||||||
f, err := os.Open(file)
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
var module string
|
|
||||||
var replaces []goReplace
|
|
||||||
inReplace := false
|
|
||||||
scanner := bufio.NewScanner(f)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
if i := strings.Index(line, "//"); i >= 0 {
|
|
||||||
line = line[:i]
|
|
||||||
}
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
switch {
|
|
||||||
case line == "":
|
|
||||||
continue
|
|
||||||
case inReplace && line == ")":
|
|
||||||
inReplace = false
|
|
||||||
continue
|
|
||||||
case strings.HasPrefix(line, "module "):
|
|
||||||
module = unquoteGoMod(strings.TrimSpace(strings.TrimPrefix(line, "module")))
|
|
||||||
continue
|
|
||||||
case line == "replace (":
|
|
||||||
inReplace = true
|
|
||||||
continue
|
|
||||||
case strings.HasPrefix(line, "replace "):
|
|
||||||
line = strings.TrimSpace(strings.TrimPrefix(line, "replace"))
|
|
||||||
case !inReplace:
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
left, right, found := strings.Cut(line, "=>")
|
|
||||||
if !found {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
from := strings.Fields(left)
|
|
||||||
to := strings.Fields(right)
|
|
||||||
if len(from) == 0 || len(to) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
target := unquoteGoMod(to[0])
|
|
||||||
// A local replacement is a path: ./, ../ or absolute. Anything else names a module version.
|
|
||||||
if strings.HasPrefix(target, "./") || strings.HasPrefix(target, "../") || target == "." || target == ".." {
|
|
||||||
replaces = append(replaces, goReplace{from: unquoteGoMod(from[0]), to: target})
|
|
||||||
} else if strings.HasPrefix(target, "/") {
|
|
||||||
return "", nil, fmt.Errorf("go.mod replaces %s with %s, outside the tree it is built from", from[0], target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
return "", nil, err
|
|
||||||
}
|
|
||||||
if module == "" {
|
|
||||||
return "", nil, fmt.Errorf("%s names no module", file)
|
|
||||||
}
|
|
||||||
// The longest replacement first, so a replaced sub-path wins over its parent.
|
|
||||||
sort.SliceStable(replaces, func(i, j int) bool { return len(replaces[i].from) > len(replaces[j].from) })
|
|
||||||
return module, replaces, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func unquoteGoMod(s string) string {
|
|
||||||
if u, err := strconv.Unquote(s); err == nil {
|
|
||||||
return u
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// goFileReads is what one Go file makes its build read: the packages it imports, and the directories its
|
|
||||||
// //go:embed patterns start in, relative to its own directory ("." for the directory itself).
|
|
||||||
//
|
|
||||||
// **A cgo preamble reaching outside its directory is refused**: a header included by a relative path, or
|
|
||||||
// a flag naming ${SRCDIR}/.., is a file of the build no import names. Inside the directory it is held
|
|
||||||
// already.
|
|
||||||
func goFileReads(file string) (imports, embeds []string, err error) {
|
|
||||||
src, err := os.ReadFile(file)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
fset := token.NewFileSet()
|
|
||||||
parsed, err := parser.ParseFile(fset, file, src, parser.ImportsOnly|parser.ParseComments)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
for _, spec := range parsed.Imports {
|
|
||||||
ip, err := strconv.Unquote(spec.Path.Value)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
if ip == "C" {
|
|
||||||
// The preamble is the comment before the import; only its #include and #cgo lines read files.
|
|
||||||
for _, cg := range parsed.Comments {
|
|
||||||
if cg.End() > spec.Pos() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, line := range strings.Split(cg.Text(), "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if (strings.HasPrefix(line, "#include") || strings.HasPrefix(line, "#cgo")) && strings.Contains(line, "..") {
|
|
||||||
return nil, nil, errors.New("its cgo preamble names a path outside its directory: " + line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A cgo file may include from below its directory: the directory is held whole.
|
|
||||||
embeds = append(embeds, ".")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
imports = append(imports, ip)
|
|
||||||
}
|
|
||||||
// //go:embed directives may stand anywhere in the file, so the whole text is read for them.
|
|
||||||
scanner := bufio.NewScanner(strings.NewReader(string(src)))
|
|
||||||
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := strings.TrimSpace(scanner.Text())
|
|
||||||
rest, ok := strings.CutPrefix(line, "//go:embed")
|
|
||||||
if !ok || (rest != "" && rest[0] != ' ' && rest[0] != '\t') {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
patterns, err := embedPatterns(rest)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
for _, p := range patterns {
|
|
||||||
embeds = append(embeds, embedRoot(p))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return imports, embeds, scanner.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// nativeSource is a file the Go command compiles or links beside Go: C, C++, Objective-C, Fortran,
|
|
||||||
// assembly, their headers and a system object.
|
|
||||||
func nativeSource(name string) bool {
|
|
||||||
switch strings.ToLower(path.Ext(name)) {
|
|
||||||
case ".c", ".h", ".cc", ".cpp", ".cxx", ".hh", ".hpp", ".hxx", ".m", ".s", ".sx", ".f", ".f90", ".for", ".syso":
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// includesStayWithin refuses a native source whose #include names a path above its directory.
|
|
||||||
func includesStayWithin(file string) error {
|
|
||||||
body, err := os.ReadFile(file)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, line := range strings.Split(string(body), "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if strings.HasPrefix(line, "#") && strings.Contains(line, "include") && strings.Contains(line, "..") {
|
|
||||||
return errors.New("it includes a path outside its directory: " + line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// embedPatterns splits a //go:embed line's patterns: separated by spaces, each possibly quoted.
|
|
||||||
func embedPatterns(s string) ([]string, error) {
|
|
||||||
var out []string
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
for s != "" {
|
|
||||||
var p string
|
|
||||||
switch s[0] {
|
|
||||||
case '"', '`':
|
|
||||||
q, err := strconv.QuotedPrefix(s)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("an embed pattern does not parse: %w", err)
|
|
||||||
}
|
|
||||||
if p, err = strconv.Unquote(q); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
s = s[len(q):]
|
|
||||||
default:
|
|
||||||
end := strings.IndexAny(s, " \t")
|
|
||||||
if end < 0 {
|
|
||||||
end = len(s)
|
|
||||||
}
|
|
||||||
p, s = s[:end], s[end:]
|
|
||||||
}
|
|
||||||
out = append(out, p)
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// embedRoot is the directory an embed pattern starts in, relative to the package: its leading elements
|
|
||||||
// without a wildcard. A pattern naming a file or a directory outright is held as itself.
|
|
||||||
func embedRoot(pattern string) string {
|
|
||||||
pattern = strings.TrimPrefix(pattern, "all:")
|
|
||||||
var kept []string
|
|
||||||
for _, el := range strings.Split(pattern, "/") {
|
|
||||||
if strings.ContainsAny(el, "*?[\\") {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
kept = append(kept, el)
|
|
||||||
}
|
|
||||||
if len(kept) == 0 {
|
|
||||||
return "."
|
|
||||||
}
|
|
||||||
return path.Clean(strings.Join(kept, "/"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// narrowTree copies into dst the files of src a build source holds, and nothing else — never `.git` — and
|
|
||||||
// returns a fingerprint of what it copied: each file's path, mode and content, hashed in path order. **A
|
|
||||||
// build handed only its build source cannot read past it** (novox/hq ADR 0267 rule 3): a recipe that
|
|
||||||
// copies a file outside it fails, naming the file, where it would have built and been missed.
|
|
||||||
func narrowTree(src, dst string, entries []string) (string, error) {
|
|
||||||
if err := os.RemoveAll(dst); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
var lines []string
|
|
||||||
err := filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
rel, err := filepath.Rel(src, p)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
rel = filepath.ToSlash(rel)
|
|
||||||
if d.IsDir() {
|
|
||||||
if d.Name() == ".git" {
|
|
||||||
return filepath.SkipDir
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !SourceHolds(entries, rel) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := filepath.Join(dst, filepath.FromSlash(rel))
|
|
||||||
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
info, err := d.Info()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if info.Mode()&fs.ModeSymlink != 0 {
|
|
||||||
// A link in the repository is copied as the link it is, and what it names said in the
|
|
||||||
// fingerprint.
|
|
||||||
target, err := os.Readlink(p)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
lines = append(lines, fmt.Sprintf("%s link %s", rel, target))
|
|
||||||
return os.Symlink(target, out)
|
|
||||||
}
|
|
||||||
if !info.Mode().IsRegular() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
in, err := os.Open(p)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer in.Close()
|
|
||||||
w, err := os.OpenFile(out, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
sum := sha256.New()
|
|
||||||
if _, err := io.Copy(io.MultiWriter(w, sum), in); err != nil {
|
|
||||||
w.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := w.Close(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
lines = append(lines, fmt.Sprintf("%s %o %s", rel, info.Mode().Perm()&0o111, hex.EncodeToString(sum.Sum(nil))))
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
sort.Strings(lines)
|
|
||||||
sum := sha256.Sum256([]byte(strings.Join(lines, "\n")))
|
|
||||||
return "narrow:" + hex.EncodeToString(sum[:]), nil
|
|
||||||
}
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
package builder
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A Go program's build source is its import closure (novox/hq ADR 0267 rule 1): never narrower than what
|
|
||||||
// `go build` reads, whatever the system, the tags, the vendoring or the embeds.
|
|
||||||
|
|
||||||
// aGoTree writes files under a fresh directory and returns it.
|
|
||||||
func aGoTree(t *testing.T, files map[string]string) string {
|
|
||||||
t.Helper()
|
|
||||||
root := t.TempDir()
|
|
||||||
for name, body := range files {
|
|
||||||
full := filepath.Join(root, filepath.FromSlash(name))
|
|
||||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return root
|
|
||||||
}
|
|
||||||
|
|
||||||
// aProgram is a module whose program imports its own packages, a vendored module, a local replacement
|
|
||||||
// that is vendored too, an embed and a package only one system builds; beside them, a package nothing
|
|
||||||
// imports and one only a test imports.
|
|
||||||
var aProgram = map[string]string{
|
|
||||||
"go.mod": "module example.com/fix\n\ngo 1.22\n\nrequire (\n\texample.org/dep v1.0.0\n\texample.net/local v0.0.0\n)\n\n" +
|
|
||||||
"replace example.net/local => ./third_party/local\n",
|
|
||||||
"go.sum": "",
|
|
||||||
"vendor/modules.txt": "# example.net/local v0.0.0 => ./third_party/local\n## explicit; go 1.22\nexample.net/local/pkg\n" +
|
|
||||||
"# example.org/dep v1.0.0\n## explicit; go 1.22\nexample.org/dep\nexample.org/dep/sub\n# example.net/local => ./third_party/local\n",
|
|
||||||
"vendor/example.org/dep/dep.go": "package dep\n\nimport _ \"example.org/dep/sub\"\n",
|
|
||||||
"vendor/example.org/dep/sub/sub.go": "package sub\n",
|
|
||||||
"vendor/example.org/other/other.go": "package other\n",
|
|
||||||
"vendor/example.net/local/pkg/p.go": "package pkg\n",
|
|
||||||
"third_party/local/go.mod": "module example.net/local\n\ngo 1.22\n",
|
|
||||||
"third_party/local/pkg/p.go": "package pkg\n",
|
|
||||||
"cmd/prog/main.go": "package main\n\nimport (\n\t\"fmt\"\n\n\t_ \"example.com/fix/emb\"\n\t\"example.com/fix/lib\"\n" +
|
|
||||||
"\t_ \"example.net/local/pkg\"\n\t_ \"example.org/dep\"\n)\n\nfunc main() { fmt.Println(lib.X) }\n",
|
|
||||||
"lib/lib.go": "package lib\n\nconst X = 1\n",
|
|
||||||
"lib/lib_plan9.go": "//go:build plan9\n\npackage lib\n\nimport _ \"example.com/fix/plan9only\"\n",
|
|
||||||
"lib/lib_test.go": "package lib\n\nimport _ \"example.com/fix/testonly\"\n",
|
|
||||||
"emb/emb_amd64.s": "",
|
|
||||||
"lib/sub/sub.go": "package sub\n",
|
|
||||||
"plan9only/p.go": "package plan9only\n",
|
|
||||||
"testonly/t.go": "package testonly\n",
|
|
||||||
"unrelated/u.go": "package unrelated\n",
|
|
||||||
"emb/emb.go": "package emb\n\nimport \"embed\"\n\n//go:embed static/*\nvar Static embed.FS\n\n" +
|
|
||||||
"//go:embed \"a b.txt\"\nvar Text string\n",
|
|
||||||
"emb/static/index.html": "x",
|
|
||||||
"emb/static/deep/style.css": "x",
|
|
||||||
"emb/a b.txt": "x",
|
|
||||||
"README.md": "x",
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAGoProgramsBuildSourceIsItsImportClosure(t *testing.T) {
|
|
||||||
root := aGoTree(t, aProgram)
|
|
||||||
got, err := GoBuildSource(root, "cmd/prog")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, c := range []struct {
|
|
||||||
file string
|
|
||||||
held bool
|
|
||||||
why string
|
|
||||||
}{
|
|
||||||
{"cmd/prog/main.go", true, "the program itself"},
|
|
||||||
{"cmd/prog/helper.go", true, "a file added to the program's package"},
|
|
||||||
{"lib/lib.go", true, "a package it imports"},
|
|
||||||
{"emb/emb_amd64.s", true, "assembly beside a package's Go"},
|
|
||||||
{"lib/lib_plan9.go", true, "a file one system builds"},
|
|
||||||
{"plan9only/p.go", true, "what a file one system builds imports"},
|
|
||||||
{"emb/static/index.html", true, "an embedded file"},
|
|
||||||
{"emb/static/deep/style.css", true, "an embedded file below the pattern's directory"},
|
|
||||||
{"emb/a b.txt", true, "an embed named outright, quoted"},
|
|
||||||
{"vendor/example.org/dep/dep.go", true, "a vendored package it imports"},
|
|
||||||
{"vendor/example.org/dep/sub/sub.go", true, "what a vendored package imports"},
|
|
||||||
{"vendor/example.net/local/pkg/p.go", true, "a replaced module, as vendored"},
|
|
||||||
{"third_party/local/pkg/p.go", true, "a replaced module, at its directory"},
|
|
||||||
{"third_party/local/go.mod", true, "a replaced module's requirements"},
|
|
||||||
{"go.mod", true, "the module's requirements"},
|
|
||||||
{"go.sum", true, "the module's sums"},
|
|
||||||
{"vendor/modules.txt", true, "the vendored modules"},
|
|
||||||
{"go.work", true, "a workspace, were one made"},
|
|
||||||
{"lib/lib_test.go", false, "a test is not built"},
|
|
||||||
{"testonly/t.go", false, "a package only a test imports"},
|
|
||||||
{"lib/sub/sub.go", false, "a package below an imported one, not imported"},
|
|
||||||
{"unrelated/u.go", false, "a package nothing imports"},
|
|
||||||
{"vendor/example.org/other/other.go", false, "a vendored package nothing imports"},
|
|
||||||
{"README.md", false, "a file no build reads"},
|
|
||||||
} {
|
|
||||||
if SourceHolds(got, c.file) != c.held {
|
|
||||||
t.Errorf("%s (%s): held %v, wanted %v\n %v", c.file, c.why, !c.held, c.held, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **Never narrower than go list -deps**: every package go names, and every file it compiles or embeds,
|
|
||||||
// is held. Where no go command is at hand, said, not passed.
|
|
||||||
goCmd, err := exec.LookPath("go")
|
|
||||||
if err != nil {
|
|
||||||
t.Skip("NOT COMPARED: no go command to list the closure with")
|
|
||||||
}
|
|
||||||
list := exec.Command(goCmd, "list", "-deps", "-f",
|
|
||||||
`{{if not .Standard}}{{$d := .Dir}}{{range .GoFiles}}{{$d}}/{{.}}
|
|
||||||
{{end}}{{range .SFiles}}{{$d}}/{{.}}
|
|
||||||
{{end}}{{range .EmbedFiles}}{{$d}}/{{.}}
|
|
||||||
{{end}}{{end}}`, "./cmd/prog")
|
|
||||||
list.Dir = root
|
|
||||||
list.Env = append(os.Environ(), "GOFLAGS=-mod=vendor", "GOPROXY=off", "GOWORK=off", "GOOS=linux", "GOARCH=amd64")
|
|
||||||
out, err := list.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("go list: %v\n%s", err, out)
|
|
||||||
}
|
|
||||||
real, _ := filepath.EvalSymlinks(root)
|
|
||||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rel, err := filepath.Rel(real, line)
|
|
||||||
if err != nil || strings.HasPrefix(rel, "..") {
|
|
||||||
rel, _ = filepath.Rel(root, line)
|
|
||||||
}
|
|
||||||
if !SourceHolds(got, filepath.ToSlash(rel)) {
|
|
||||||
t.Errorf("go list builds %s, and the build source does not hold it", rel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A file added to the program's import closure moves its build source with it: the closure read again
|
|
||||||
// grows by the package.
|
|
||||||
func TestTheBuildSourceGrowsWithAnImport(t *testing.T) {
|
|
||||||
files := map[string]string{}
|
|
||||||
for k, v := range aProgram {
|
|
||||||
files[k] = v
|
|
||||||
}
|
|
||||||
before, err := GoBuildSource(aGoTree(t, files), "cmd/prog")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if SourceHolds(before, "unrelated/u.go") {
|
|
||||||
t.Fatal("held before it was imported")
|
|
||||||
}
|
|
||||||
files["cmd/prog/more.go"] = "package main\n\nimport _ \"example.com/fix/unrelated\"\n"
|
|
||||||
after, err := GoBuildSource(aGoTree(t, files), "cmd/prog")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !SourceHolds(after, "unrelated/u.go") {
|
|
||||||
t.Fatalf("an import added did not grow the build source: %v", after)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// What cannot be read is refused, so the build source is not narrowed and nothing is missed.
|
|
||||||
func TestABuildSourceThatCannotBeReadIsRefused(t *testing.T) {
|
|
||||||
for _, c := range []struct {
|
|
||||||
what string
|
|
||||||
files map[string]string
|
|
||||||
pkg string
|
|
||||||
says string
|
|
||||||
}{
|
|
||||||
{"no go.mod", map[string]string{"cmd/p/main.go": "package main\n"}, "cmd/p", "no go.mod"},
|
|
||||||
{"a workspace", map[string]string{"go.mod": "module x\n", "go.work": "go 1.22\n", "p/main.go": "package main\n"},
|
|
||||||
"p", "go.work"},
|
|
||||||
{"a local replacement outside the tree", map[string]string{
|
|
||||||
"go.mod": "module x\n\nreplace y => ../y\n", "p/main.go": "package main\n\nimport _ \"y\"\n"}, "p", "outside"},
|
|
||||||
{"a cgo header outside the directory", map[string]string{
|
|
||||||
"go.mod": "module x\n", "p/main.go": "package main\n\n// #include \"../h/h.h\"\nimport \"C\"\n"}, "p", "cgo"},
|
|
||||||
{"an assembly include above its directory", map[string]string{"go.mod": "module x\n", "p/main.go": "package main\n",
|
|
||||||
"p/a_amd64.s": "#include \"../h/textflag.h\"\n"}, "p", "outside"},
|
|
||||||
{"a file that does not parse", map[string]string{"go.mod": "module x\n", "p/main.go": "package main\n\nimport (\n"},
|
|
||||||
"p", "main.go"},
|
|
||||||
{"a package that leaves the tree", map[string]string{"go.mod": "module x\n"}, "../elsewhere", "leaves"},
|
|
||||||
} {
|
|
||||||
_, err := GoBuildSource(aGoTree(t, c.files), c.pkg)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), c.says) {
|
|
||||||
t.Errorf("%s: %v, wanted a refusal saying %q", c.what, err, c.says)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A cgo header in the package's own directory is held already, and is no refusal.
|
|
||||||
if _, err := GoBuildSource(aGoTree(t, map[string]string{"go.mod": "module x\n",
|
|
||||||
"p/main.go": "package main\n\n// #include \"h.h\"\nimport \"C\"\n"}), "p"); err != nil {
|
|
||||||
t.Errorf("a header in the package's directory was refused: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestABuildSourceHoldsWhatItsEntriesSay(t *testing.T) {
|
|
||||||
entries := []string{"./", "cmd/p/", "web/**", "go.mod"}
|
|
||||||
for file, want := range map[string]bool{
|
|
||||||
"main.go": true, "main_test.go": false, "cmd/p/x.go": true, "cmd/p/x_test.go": false, "cmd/p/sub/y.go": false,
|
|
||||||
"cmd/px/x.go": false, "web/a/b/c.css": true, "web": true, "webx/a": false, "go.mod": true, "docs/x.md": false,
|
|
||||||
"/cmd/p/x.go": true, "cmd/p/../q/x.go": false,
|
|
||||||
} {
|
|
||||||
if SourceHolds(entries, file) != want {
|
|
||||||
t.Errorf("%s: held %v, wanted %v", file, !want, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !SourceHolds([]string{"**"}, "anything/at/all") {
|
|
||||||
t.Error("the whole tree does not hold a file")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **A build handed its build source reads nothing else** (rule 3): the narrowed tree holds the source's
|
|
||||||
// files and no others — never .git — and its fingerprint changes with them and only with them.
|
|
||||||
func TestANarrowedTreeHoldsTheBuildSourceAndNothingElse(t *testing.T) {
|
|
||||||
files := map[string]string{}
|
|
||||||
for k, v := range aProgram {
|
|
||||||
files[k] = v
|
|
||||||
}
|
|
||||||
files[".git/HEAD"] = "ref: refs/heads/main\n"
|
|
||||||
src := aGoTree(t, files)
|
|
||||||
entries, err := GoBuildSource(src, "cmd/prog")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
dst := filepath.Join(t.TempDir(), "narrow")
|
|
||||||
one, err := narrowTree(src, dst, entries)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for file, want := range map[string]bool{"cmd/prog/main.go": true, "lib/lib.go": true, "emb/static/deep/style.css": true,
|
|
||||||
"lib/lib_test.go": false, "unrelated/u.go": false, "README.md": false, ".git/HEAD": false} {
|
|
||||||
_, err := os.Stat(filepath.Join(dst, filepath.FromSlash(file)))
|
|
||||||
if (err == nil) != want {
|
|
||||||
t.Errorf("%s: copied %v, wanted %v", file, err == nil, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Outside the build source: one fingerprint.
|
|
||||||
files["README.md"] = "changed"
|
|
||||||
files["unrelated/u.go"] = "package unrelated\n\nconst Changed = 1\n"
|
|
||||||
again, err := narrowTree(aGoTree(t, files), filepath.Join(t.TempDir(), "n"), entries)
|
|
||||||
if err != nil || again != one {
|
|
||||||
t.Fatalf("a change outside the build source changed its fingerprint: %s %s %v", one, again, err)
|
|
||||||
}
|
|
||||||
// Inside it: another.
|
|
||||||
files["lib/lib.go"] = "package lib\n\nconst X = 2\n"
|
|
||||||
moved, err := narrowTree(aGoTree(t, files), filepath.Join(t.TempDir(), "n"), entries)
|
|
||||||
if err != nil || moved == one {
|
|
||||||
t.Fatalf("a change inside the build source kept its fingerprint: %s %v", moved, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This repository's own programs: the route proxy's build source is not the controller's, and neither
|
|
||||||
// holds the other's command.
|
|
||||||
func TestThisRepositorysProgramsHaveBuildSourcesOfTheirOwn(t *testing.T) {
|
|
||||||
root := filepath.Join("..", "..")
|
|
||||||
proxy, err := GoBuildSource(root, "examples/route-proxy")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
controller, err := GoBuildSource(root, "cmd/mesh-controller")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
builder, err := GoBuildSource(root, "cmd/mesh-builder")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, c := range []struct {
|
|
||||||
entries []string
|
|
||||||
name string
|
|
||||||
file string
|
|
||||||
held bool
|
|
||||||
}{
|
|
||||||
{proxy, "the route proxy", "examples/route-proxy/main.go", true},
|
|
||||||
{proxy, "the route proxy", "internal/broker/broker.go", true},
|
|
||||||
{proxy, "the route proxy", "cmd/mesh-controller/main.go", false},
|
|
||||||
{proxy, "the route proxy", "internal/conditions/condition.go", false},
|
|
||||||
{proxy, "the route proxy", "README.md", false},
|
|
||||||
{controller, "the controller", "cmd/mesh-controller/main.go", true},
|
|
||||||
{controller, "the controller", "internal/conditions/condition.go", true},
|
|
||||||
{controller, "the controller", "internal/inventory/migrations/0001-nodes.sql", true},
|
|
||||||
{controller, "the controller", "examples/route-proxy/main.go", false},
|
|
||||||
{controller, "the controller", "cmd/mesh-builder/main.go", false},
|
|
||||||
{controller, "the controller", "README.md", false},
|
|
||||||
{builder, "the build seat's program", "cmd/mesh-builder/main.go", true},
|
|
||||||
{builder, "the build seat's program", "internal/conditions/condition.go", false},
|
|
||||||
{builder, "the build seat's program", "cmd/mesh-controller/main.go", false},
|
|
||||||
} {
|
|
||||||
if SourceHolds(c.entries, c.file) != c.held {
|
|
||||||
t.Errorf("%s: %s held %v, wanted %v", c.name, c.file, !c.held, c.held)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// C or assembly beside Go holds its package's directory whole — an include may name a file below it — and a
|
|
||||||
// go.mod made between the module's root and a package is a change.
|
|
||||||
func TestNativeSourcesHoldTheirDirectoryWhole(t *testing.T) {
|
|
||||||
got, err := GoBuildSource(aGoTree(t, map[string]string{"go.mod": "module x\n", "cmd/p/main.go": "package main\n\nimport _ \"x/lib/asm\"\n",
|
|
||||||
"lib/asm/a.go": "package asm\n", "lib/asm/a_amd64.s": "#include \"inc/textflag.h\"\n", "lib/asm/inc/textflag.h": ""}), "cmd/p")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for file, want := range map[string]bool{"lib/asm/inc/textflag.h": true, "lib/asm/a_amd64.s": true, "lib/go.mod": true,
|
|
||||||
"lib/asm/go.mod": true, "cmd/go.mod": true, "other/go.mod": false} {
|
|
||||||
if SourceHolds(got, file) != want {
|
|
||||||
t.Errorf("%s: held %v, wanted %v (%v)", file, !want, want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A cgo file may include from below its directory with nothing native beside it: its directory is held whole.
|
|
||||||
func TestACgoFileHoldsItsDirectoryWhole(t *testing.T) {
|
|
||||||
got, err := GoBuildSource(aGoTree(t, map[string]string{"go.mod": "module x\n",
|
|
||||||
"p/main.go": "package main\n\n// #include \"inc/x.h\"\nimport \"C\"\n", "p/inc/x.h": ""}), "p")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !SourceHolds(got, "p/inc/x.h") {
|
|
||||||
t.Fatalf("a header a cgo preamble includes is not held: %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-145
@@ -9,8 +9,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// A build's source fingerprint: what it was made from, hashed (novox/hq issue 280).
|
// A build's source fingerprint: what it was made from, hashed (novox/hq issue 280).
|
||||||
@@ -51,152 +49,10 @@ type sourceInputs struct {
|
|||||||
toolchains map[string]string
|
toolchains map[string]string
|
||||||
// unpinned is why this build has no fingerprint: empty when it has one.
|
// unpinned is why this build has no fingerprint: empty when it has one.
|
||||||
unpinned string
|
unpinned string
|
||||||
|
|
||||||
// prefix is the module's directory within its repository, empty at the root.
|
|
||||||
prefix string
|
|
||||||
// own is the module's build source in its own repository, relative to the module's directory, and
|
|
||||||
// whole when an artifact's is not known (novox/hq ADR 0267).
|
|
||||||
own map[string]bool
|
|
||||||
ownIsAll bool
|
|
||||||
// read is, per context (repository and ref), the build source read there; nil for one read whole.
|
|
||||||
read map[string]map[string]bool
|
|
||||||
readAs map[string]catalogue.ArtifactContext
|
|
||||||
// contextsAtHead is false once a context was cloned at something other than its trunk's head: its
|
|
||||||
// closure is not the one the next merge there meets, and the build says no build source.
|
|
||||||
contextsAtHead bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// contextOffHead says a context was not its trunk's head.
|
|
||||||
func (s *sourceInputs) contextOffHead() {
|
|
||||||
if s != nil {
|
|
||||||
s.contextsAtHead = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSourceInputs(module string) *sourceInputs {
|
func newSourceInputs(module string) *sourceInputs {
|
||||||
return &sourceInputs{module: module, contexts: map[string]string{}, toolchains: map[string]string{},
|
return &sourceInputs{module: module, contexts: map[string]string{}, toolchains: map[string]string{}}
|
||||||
own: map[string]bool{}, read: map[string]map[string]bool{}, readAs: map[string]catalogue.ArtifactContext{},
|
|
||||||
contextsAtHead: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ownHas adds entries, relative to the module's directory, to its build source in its own repository.
|
|
||||||
func (s *sourceInputs) ownHas(entries ...string) {
|
|
||||||
if s == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, e := range entries {
|
|
||||||
s.own[e] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ownWhole says an artifact's build source in the module's own repository is not known: the module's
|
|
||||||
// whole directory is its source, as it was before.
|
|
||||||
func (s *sourceInputs) ownWhole() {
|
|
||||||
if s != nil {
|
|
||||||
s.ownIsAll = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func contextKey(c catalogue.ArtifactContext) string { return c.Repository + "#" + c.Ref }
|
|
||||||
|
|
||||||
// readIn adds entries to the build source read in a context; one read whole stays whole.
|
|
||||||
func (s *sourceInputs) readIn(c catalogue.ArtifactContext, entries []string) {
|
|
||||||
if s == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
key := contextKey(c)
|
|
||||||
s.readAs[key] = c
|
|
||||||
set, known := s.read[key]
|
|
||||||
if known && set == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if set == nil {
|
|
||||||
set = map[string]bool{}
|
|
||||||
s.read[key] = set
|
|
||||||
}
|
|
||||||
for _, e := range entries {
|
|
||||||
set[e] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// readWhole says a context is read whole by an artifact.
|
|
||||||
func (s *sourceInputs) readWhole(c catalogue.ArtifactContext) {
|
|
||||||
if s == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
key := contextKey(c)
|
|
||||||
s.readAs[key] = c
|
|
||||||
s.read[key] = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildSources is what the build says it was made from, per repository: its own (module.json always,
|
|
||||||
// and every artifact's) unless an artifact's is not known, and each context not read whole.
|
|
||||||
func (s *sourceInputs) buildSources() []BuildSource {
|
|
||||||
if s == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var out []BuildSource
|
|
||||||
if !s.ownIsAll {
|
|
||||||
own := []string{withPrefix(s.prefix, ManifestName)}
|
|
||||||
for e := range s.own {
|
|
||||||
own = append(own, withPrefix(s.prefix, e))
|
|
||||||
}
|
|
||||||
sort.Strings(own)
|
|
||||||
out = append(out, BuildSource{Paths: compactSorted(own)})
|
|
||||||
}
|
|
||||||
var keys []string
|
|
||||||
for k := range s.read {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
for _, k := range keys {
|
|
||||||
set := s.read[k]
|
|
||||||
if set == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var paths []string
|
|
||||||
for e := range set {
|
|
||||||
paths = append(paths, e)
|
|
||||||
}
|
|
||||||
sort.Strings(paths)
|
|
||||||
c := s.readAs[k]
|
|
||||||
out = append(out, BuildSource{Repository: c.Repository, Ref: c.Ref, Paths: paths})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// withPrefix is an entry relative to the module's directory made relative to its repository's root.
|
|
||||||
func withPrefix(prefix, entry string) string {
|
|
||||||
switch {
|
|
||||||
case entry == "." || entry == "./":
|
|
||||||
entry = ""
|
|
||||||
case entry == "**" || entry == "./**" || entry == "/**":
|
|
||||||
if prefix == "" {
|
|
||||||
return "**"
|
|
||||||
}
|
|
||||||
return prefix + "/**"
|
|
||||||
}
|
|
||||||
entry = strings.TrimPrefix(entry, "./")
|
|
||||||
if prefix == "" {
|
|
||||||
if entry == "" {
|
|
||||||
return "./"
|
|
||||||
}
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
if entry == "" {
|
|
||||||
return prefix + "/"
|
|
||||||
}
|
|
||||||
return prefix + "/" + entry
|
|
||||||
}
|
|
||||||
|
|
||||||
func compactSorted(in []string) []string {
|
|
||||||
var out []string
|
|
||||||
for i, e := range in {
|
|
||||||
if i == 0 || e != in[i-1] {
|
|
||||||
out = append(out, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// notPinned marks the build as one its source does not pin; the first reason stands.
|
// notPinned marks the build as one its source does not pin; the first reason stands.
|
||||||
|
|||||||
@@ -322,17 +322,6 @@ func (b *Build) problems(module string) []string {
|
|||||||
"everything else brings its own recipe", module, a.Name, a.Kind))
|
"everything else brings its own recipe", module, a.Name, a.Kind))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if a.Compiles != "" {
|
|
||||||
if a.Kind != ArtifactImage {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q is a %q and names a Go package it compiles. Only an image's recipe is "+
|
|
||||||
"told which; a bundle names its package in from (novox/hq ADR 0267)", module, a.Name, a.Kind))
|
|
||||||
} else if c := strings.TrimSpace(a.Compiles); strings.HasPrefix(c, "/") || c == ".." ||
|
|
||||||
strings.HasPrefix(c, "../") || strings.Contains(c, "/../") || strings.HasSuffix(c, "/..") {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q compiles %q, which leaves the tree it is built in", module, a.Name, a.Compiles))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// An upstream image is named, not read from the repository, so the path rule does not
|
// An upstream image is named, not read from the repository, so the path rule does not
|
||||||
// apply to it — and applying it anyway would refuse every reference with a registry host
|
// apply to it — and applying it anyway would refuse every reference with a registry host
|
||||||
// in it.
|
// in it.
|
||||||
|
|||||||
@@ -180,24 +180,3 @@ func TestAResourceNamingAPackageIsRefused(t *testing.T) {
|
|||||||
t.Fatal("a resource backed by a package was accepted; a package is not a resource")
|
t.Fatal("a resource backed by a package was accepted; a package is not a resource")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// An image names the Go package its recipe compiles (novox/hq ADR 0267): within the tree it is built in,
|
|
||||||
// and only an image says one.
|
|
||||||
func TestOnlyAnImageNamesThePackageItCompilesWithinItsTree(t *testing.T) {
|
|
||||||
m, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
|
||||||
{"name":"x","kind":"image","from":"Dockerfile","compiles":"cmd/x"}]}}`))
|
|
||||||
if err != nil || m.Build.Artifacts[0].Compiles != "cmd/x" {
|
|
||||||
t.Fatalf("an image naming its package was refused or lost it: %v", err)
|
|
||||||
}
|
|
||||||
for _, c := range []struct{ artifact, says string }{
|
|
||||||
{`{"name":"x","kind":"archive","from":"files","compiles":"cmd/x"}`, "Only an image"},
|
|
||||||
{`{"name":"x","kind":"image","from":"Dockerfile","compiles":"../x"}`, "leaves the tree"},
|
|
||||||
{`{"name":"x","kind":"image","from":"Dockerfile","compiles":"/x"}`, "leaves the tree"},
|
|
||||||
{`{"name":"x","kind":"image","from":"Dockerfile","compiles":"a/../../x"}`, "leaves the tree"},
|
|
||||||
} {
|
|
||||||
_, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[` + c.artifact + `]}}`))
|
|
||||||
if err == nil || !strings.Contains(err.Error(), c.says) {
|
|
||||||
t.Errorf("%s: %v, wanted a refusal saying %q", c.artifact, err, c.says)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -905,16 +905,6 @@ type Artifact struct {
|
|||||||
// image built from this same module's own repository, the same as every other artifact.
|
// image built from this same module's own repository, the same as every other artifact.
|
||||||
Context *ArtifactContext `json:"context,omitempty"`
|
Context *ArtifactContext `json:"context,omitempty"`
|
||||||
|
|
||||||
// Compiles is the Go package an image's recipe compiles, relative to the tree it is built in — its
|
|
||||||
// context, or the module's directory (novox/hq ADR 0267 rule 1).
|
|
||||||
//
|
|
||||||
// **What a build is made from is derived, never listed.** The build seat reads the package's import
|
|
||||||
// closure and hands the recipe that and nothing else, so a merge elsewhere in a shared repository is
|
|
||||||
// no change to this image, and a recipe copying a file outside the closure fails by name instead of
|
|
||||||
// building from something no merge is mapped onto. Empty for an image that compiles no Go: its whole
|
|
||||||
// tree is its build source, as before.
|
|
||||||
Compiles string `json:"compiles,omitempty"`
|
|
||||||
|
|
||||||
// System is the operating system this artifact is compiled for, for a bundle whose output is a
|
// System is the operating system this artifact is compiled for, for a bundle whose output is a
|
||||||
// binary rather than portable code (novox/hq ADR 0142).
|
// binary rather than portable code (novox/hq ADR 0142).
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -230,14 +230,6 @@ type Shelf map[string]Manifest
|
|||||||
//
|
//
|
||||||
// Run at registration, which is the last moment the mesh can still refuse: after it, a caller is
|
// Run at registration, which is the last moment the mesh can still refuse: after it, a caller is
|
||||||
// bound to a seat and a refusal is an outage rather than a conversation.
|
// bound to a seat and a refusal is an outage rather than a conversation.
|
||||||
// UndeclaredSeat ends the problem of a `uses` or a claim naming a seat no manifest given declares: over the whole
|
|
||||||
// catalogue (registration, the catalogue's own check) a refusal, and over some manifests alone a seat whose
|
|
||||||
// declaring module was not given (novox/hq issue 364).
|
|
||||||
const UndeclaredSeat = "which no module declares and the mesh does not define"
|
|
||||||
|
|
||||||
// IsUndeclaredSeat says whether a problem CatalogueProblems gave is a seat no manifest given declares.
|
|
||||||
func IsUndeclaredSeat(problem string) bool { return strings.HasSuffix(problem, UndeclaredSeat) }
|
|
||||||
|
|
||||||
func CatalogueProblems(shelf Shelf) []string {
|
func CatalogueProblems(shelf Shelf) []string {
|
||||||
var problems []string
|
var problems []string
|
||||||
|
|
||||||
@@ -290,7 +282,7 @@ func CatalogueProblems(shelf Shelf) []string {
|
|||||||
for _, u := range m.Uses {
|
for _, u := range m.Uses {
|
||||||
if !exists(u) {
|
if !exists(u) {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s uses the seat %q, "+UndeclaredSeat,
|
"%s uses the seat %q, which no module declares and the mesh does not define",
|
||||||
module, u))
|
module, u))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -298,7 +290,7 @@ func CatalogueProblems(shelf Shelf) []string {
|
|||||||
for _, c := range m.Claims {
|
for _, c := range m.Claims {
|
||||||
if !exists(c.Name) {
|
if !exists(c.Name) {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s claims the seat %q, "+UndeclaredSeat,
|
"%s claims the seat %q, which no module declares and the mesh does not define",
|
||||||
module, c.Name))
|
module, c.Name))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -338,6 +338,15 @@ var ControllerVerbs = []Verb{
|
|||||||
"why": "what the drill tests",
|
"why": "what the drill tests",
|
||||||
"condition": "the key of the condition the drill is meant to raise, if any (optional)",
|
"condition": "the key of the condition the drill is meant to raise, if any (optional)",
|
||||||
}, []string{"what", "why"})},
|
}, []string{"what", "why"})},
|
||||||
|
{Name: "warranted", Description: "Record in the hand-act log what a module did on the operator's warrant (novox/hq " +
|
||||||
|
"ADR 0274, ADR 0259): who chose, through which channel, with which proofs and which answer are read from the " +
|
||||||
|
"router's own record of that module's ask, never from the caller; recorded once per ask however often it is " +
|
||||||
|
"asked; the caller names the ask and says nothing else. Refused for an ask still open, ended without a choice, " +
|
||||||
|
"or not that module's.",
|
||||||
|
Input: schema(map[string]string{
|
||||||
|
"asker": "the module that asked, e.g. claude-code",
|
||||||
|
"ask": "its ask's id",
|
||||||
|
}, []string{"asker", "ask"})},
|
||||||
{Name: "hand-acts", Description: "What was done by hand lately — pushes, plans ended, consumers re-made, acts " +
|
{Name: "hand-acts", Description: "What was done by hand lately — pushes, plans ended, consumers re-made, acts " +
|
||||||
"recorded — who, why and the cause of each, and which causes repeat: each repeat is a healer the mesh lacks.",
|
"recorded — who, why and the cause of each, and which causes repeat: each repeat is a healer the mesh lacks.",
|
||||||
Input: schema(map[string]string{"days": "how many days back (default 14)"}, nil)},
|
Input: schema(map[string]string{"days": "how many days back (default 14)"}, nil)},
|
||||||
|
|||||||
@@ -226,21 +226,10 @@ type Module struct {
|
|||||||
RollOut bool `json:"roll-out,omitempty"`
|
RollOut bool `json:"roll-out,omitempty"`
|
||||||
// Reads are the other repositories its build read source from.
|
// Reads are the other repositories its build read source from.
|
||||||
Reads []string `json:"reads,omitempty"`
|
Reads []string `json:"reads,omitempty"`
|
||||||
// Sources are the build source its newest build said it read, per repository (novox/hq ADR 0267): a
|
|
||||||
// context of Reads by name with its paths, or the module's own repository (Own). Absent, the module's
|
|
||||||
// build reads every file of each of Reads and of its own directory, as before.
|
|
||||||
Sources []BuildSource `json:"sources,omitempty"`
|
|
||||||
// Manifest is the module as the mesh holds it: artifacts resolved to the builds it runs.
|
// Manifest is the module as the mesh holds it: artifacts resolved to the builds it runs.
|
||||||
Manifest json.RawMessage `json:"manifest"`
|
Manifest json.RawMessage `json:"manifest"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildSource is the build source a module's build read in one repository (novox/hq ADR 0267).
|
|
||||||
type BuildSource struct {
|
|
||||||
Repository string `json:"repository,omitempty"`
|
|
||||||
Own bool `json:"own,omitempty"`
|
|
||||||
Paths []string `json:"paths"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Edge is one build dependency: From is built standing on To.
|
// Edge is one build dependency: From is built standing on To.
|
||||||
type Edge struct {
|
type Edge struct {
|
||||||
From string `json:"from"`
|
From string `json:"from"`
|
||||||
|
|||||||
+12
-122
@@ -45,9 +45,6 @@ type Build struct {
|
|||||||
// Read is every repository this build read source from besides the module's own (novox/hq
|
// Read is every repository this build read source from besides the module's own (novox/hq
|
||||||
// 04-ISSUES/131), at the ref it read.
|
// 04-ISSUES/131), at the ref it read.
|
||||||
Read []ReadRepository
|
Read []ReadRepository
|
||||||
// Sources are what the build was made from, as files, per repository (novox/hq ADR 0267): said by the
|
|
||||||
// builder for a build of a trunk commit; nil where it said none.
|
|
||||||
Sources []BuildSource
|
|
||||||
// SourceFingerprint is what the build was made from, hashed, as its builder said it (novox/hq
|
// SourceFingerprint is what the build was made from, hashed, as its builder said it (novox/hq
|
||||||
// issue 280); empty from a builder that predates it, or where the source does not pin the build.
|
// issue 280); empty from a builder that predates it, or where the source does not pin the build.
|
||||||
SourceFingerprint string
|
SourceFingerprint string
|
||||||
@@ -78,34 +75,9 @@ func (b Build) AskedOrAt() time.Time {
|
|||||||
const newestRequestFirst = `coalesce(asked, at) desc, at desc`
|
const newestRequestFirst = `coalesce(asked, at) desc, at desc`
|
||||||
|
|
||||||
// ReadRepository is a repository a build read source from besides the module's own.
|
// ReadRepository is a repository a build read source from besides the module's own.
|
||||||
//
|
|
||||||
// Paths and Own are never stored in a build's `built_contexts` (a controller that predates them would read
|
|
||||||
// an own entry as a context, and every module of a repository as packaging every other): ReadRepositories
|
|
||||||
// lays them over what it reads, from the build's build sources (novox/hq ADR 0267).
|
|
||||||
type ReadRepository struct {
|
type ReadRepository struct {
|
||||||
Repository string `json:"repository"`
|
Repository string `json:"repository"`
|
||||||
Ref string `json:"ref,omitempty"`
|
Ref string `json:"ref,omitempty"`
|
||||||
// Paths are the build source the build read in this repository (builder.SourceHolds): a changed file
|
|
||||||
// outside them is no change to the module. Empty is the whole repository, as before.
|
|
||||||
Paths []string `json:"paths,omitempty"`
|
|
||||||
// Own is the module's own repository, whose Paths narrow what its own directory — or, for a module
|
|
||||||
// built from its repository's root, the whole repository — would otherwise be.
|
|
||||||
Own bool `json:"own,omitempty"`
|
|
||||||
// Built is when the build these were read from was asked, and Looked when the module's newest build of
|
|
||||||
// any outcome was: what the planner judges a build source's age, and a merge's news, by. Never stored.
|
|
||||||
Built time.Time `json:"-"`
|
|
||||||
Looked time.Time `json:"-"`
|
|
||||||
// LookedAt are the merge commits a plan that built the module, or is building it, answered: a merge
|
|
||||||
// of one of them is history for the module whatever the clocks say. Never stored.
|
|
||||||
LookedAt []string `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildSource is the build source a build read in one repository (novox/hq ADR 0267): Repository and Ref
|
|
||||||
// a context's, empty for the module's own.
|
|
||||||
type BuildSource struct {
|
|
||||||
Repository string `json:"repository,omitempty"`
|
|
||||||
Ref string `json:"ref,omitempty"`
|
|
||||||
Paths []string `json:"paths"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Artifact is one thing a build published.
|
// Artifact is one thing a build published.
|
||||||
@@ -136,14 +108,6 @@ func (i *Inventory) RecordBuild(ctx context.Context, b Build) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var sources any
|
|
||||||
if len(b.Sources) > 0 {
|
|
||||||
raw, err := json.Marshal(b.Sources)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
sources = raw
|
|
||||||
}
|
|
||||||
var module *string
|
var module *string
|
||||||
if b.Module != "" {
|
if b.Module != "" {
|
||||||
module = &b.Module
|
module = &b.Module
|
||||||
@@ -154,12 +118,11 @@ func (i *Inventory) RecordBuild(ctx context.Context, b Build) error {
|
|||||||
}
|
}
|
||||||
_, err = i.store.Pool().Exec(ctx,
|
_, err = i.store.Pool().Exec(ctx,
|
||||||
`insert into build (id, repository, ref, module, commit_hash, built_on, failed, made,
|
`insert into build (id, repository, ref, module, commit_hash, built_on, failed, made,
|
||||||
source_path, manifest, built_against, built_contexts, asked, source_fingerprint,
|
source_path, manifest, built_against, built_contexts, asked, source_fingerprint)
|
||||||
build_sources)
|
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
|
||||||
on conflict (id) do nothing`,
|
on conflict (id) do nothing`,
|
||||||
b.ID, b.Repository, b.Ref, module, b.Commit, b.On, b.Failed, made,
|
b.ID, b.Repository, b.Ref, module, b.Commit, b.On, b.Failed, made,
|
||||||
b.Path, manifestOrNil(b.Manifest), against, read, asked, b.SourceFingerprint, sources)
|
b.Path, manifestOrNil(b.Manifest), against, read, asked, b.SourceFingerprint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -339,47 +302,9 @@ func (i *Inventory) BuiltAgainst(ctx context.Context) (map[string][]string, erro
|
|||||||
// The mirror of BuiltAgainst, and derived the same way and for the same reason: a merge into a
|
// The mirror of BuiltAgainst, and derived the same way and for the same reason: a merge into a
|
||||||
// repository a module only packages is a change to that module, and the manifest the mesh keeps
|
// repository a module only packages is a change to that module, and the manifest the mesh keeps
|
||||||
// carries nothing that would say so (novox/hq 04-ISSUES/131).
|
// carries nothing that would say so (novox/hq 04-ISSUES/131).
|
||||||
//
|
|
||||||
// **With the build source that build said** (novox/hq ADR 0267): a context's entry carries the paths the
|
|
||||||
// build read there, and the module's own repository an entry marked Own with its paths. A build that said
|
|
||||||
// none — off the trunk, from a builder that predates it, or of a source not known — leaves its module read
|
|
||||||
// as before: every file of a context, and its own directory or root.
|
|
||||||
func (i *Inventory) ReadRepositories(ctx context.Context) (map[string][]ReadRepository, error) {
|
func (i *Inventory) ReadRepositories(ctx context.Context) (map[string][]ReadRepository, error) {
|
||||||
// The newest build of each module whatever its outcome: a failed one newer than the newest that
|
|
||||||
// worked leaves that one's build source stale — the merge it was asked for may have changed the closure
|
|
||||||
// (novox/hq ADR 0267), so its module is read whole until a build works again.
|
|
||||||
newest := map[string]struct {
|
|
||||||
at time.Time
|
|
||||||
failed bool
|
|
||||||
}{}
|
|
||||||
tried, err := i.store.Pool().Query(ctx,
|
|
||||||
`select distinct on (module) module, coalesce(asked, at), failed <> ''
|
|
||||||
from build
|
|
||||||
where module is not null and module <> ''
|
|
||||||
order by module, `+newestRequestFirst)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for tried.Next() {
|
|
||||||
var module string
|
|
||||||
var at time.Time
|
|
||||||
var failed bool
|
|
||||||
if err := tried.Scan(&module, &at, &failed); err != nil {
|
|
||||||
tried.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
newest[module] = struct {
|
|
||||||
at time.Time
|
|
||||||
failed bool
|
|
||||||
}{at, failed}
|
|
||||||
}
|
|
||||||
tried.Close()
|
|
||||||
if err := tried.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := i.store.Pool().Query(ctx,
|
rows, err := i.store.Pool().Query(ctx,
|
||||||
`select distinct on (module) module, built_contexts, build_sources, coalesce(asked, at)
|
`select distinct on (module) module, built_contexts
|
||||||
from build
|
from build
|
||||||
where module is not null and module <> '' and failed = ''
|
where module is not null and module <> '' and failed = ''
|
||||||
order by module, `+newestRequestFirst)
|
order by module, `+newestRequestFirst)
|
||||||
@@ -391,59 +316,24 @@ func (i *Inventory) ReadRepositories(ctx context.Context) (map[string][]ReadRepo
|
|||||||
read := map[string][]ReadRepository{}
|
read := map[string][]ReadRepository{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var module string
|
var module string
|
||||||
var raw, rawSources []byte
|
var raw []byte
|
||||||
var built time.Time
|
if err := rows.Scan(&module, &raw); err != nil {
|
||||||
if err := rows.Scan(&module, &raw, &rawSources, &built); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if len(raw) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
var of []ReadRepository
|
var of []ReadRepository
|
||||||
if len(raw) > 0 {
|
if err := json.Unmarshal(raw, &of); err != nil {
|
||||||
if err := json.Unmarshal(raw, &of); err != nil {
|
continue
|
||||||
of = nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
var sources []BuildSource
|
if len(of) > 0 {
|
||||||
if len(rawSources) > 0 {
|
|
||||||
if err := json.Unmarshal(rawSources, &sources); err != nil {
|
|
||||||
sources = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if n, known := newest[module]; known && n.failed && n.at.After(built) {
|
|
||||||
sources = nil
|
|
||||||
}
|
|
||||||
if of = WithBuildSources(of, sources); len(of) > 0 {
|
|
||||||
for k := range of {
|
|
||||||
of[k].Built, of[k].Looked = built, newest[module].at
|
|
||||||
}
|
|
||||||
read[module] = of
|
read[module] = of
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return read, rows.Err()
|
return read, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithBuildSources lays a build's build sources over the repositories it read: a context's paths on its
|
|
||||||
// entry, and the module's own as an entry of its own. A context the build read that its sources do not
|
|
||||||
// name stays whole; a source naming a context the build did not say it read is dropped, since nothing
|
|
||||||
// moves a module through a repository it is not recorded as reading.
|
|
||||||
func WithBuildSources(read []ReadRepository, sources []BuildSource) []ReadRepository {
|
|
||||||
out := make([]ReadRepository, 0, len(read)+1)
|
|
||||||
for _, r := range read {
|
|
||||||
r.Paths, r.Own = nil, false
|
|
||||||
for _, s := range sources {
|
|
||||||
if s.Repository != "" && s.Repository == r.Repository && s.Ref == r.Ref && len(s.Paths) > 0 {
|
|
||||||
r.Paths = append([]string(nil), s.Paths...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out = append(out, r)
|
|
||||||
}
|
|
||||||
for _, s := range sources {
|
|
||||||
if s.Repository == "" && len(s.Paths) > 0 {
|
|
||||||
out = append(out, ReadRepository{Ref: s.Ref, Paths: append([]string(nil), s.Paths...), Own: true})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// manifestOrNil keeps the difference between "declared nothing" and "predates this being kept".
|
// manifestOrNil keeps the difference between "declared nothing" and "predates this being kept".
|
||||||
//
|
//
|
||||||
// A build recorded before the mesh kept manifests has no manifest, and that is not the same as one
|
// A build recorded before the mesh kept manifests has no manifest, and that is not the same as one
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ package inventory
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"reflect"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// A build result was answered to whoever asked and kept nowhere, so "when did this last build",
|
// A build result was answered to whoever asked and kept nowhere, so "when did this last build",
|
||||||
@@ -171,79 +169,3 @@ func TestWhatABuildReadComesBackForTheNewestBuildOfEachModule(t *testing.T) {
|
|||||||
t.Fatal("a failed build's reading was kept as what that module reads")
|
t.Fatal("a failed build's reading was kept as what that module reads")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// **What a build said it was made from comes back laid over what it read** (novox/hq ADR 0267): a context's
|
|
||||||
// paths on its entry, the module's own as an entry marked Own — and never in the stored `built_contexts`,
|
|
||||||
// where a controller that predates them would read an own entry as a context of its own repository.
|
|
||||||
func TestABuildsBuildSourceComesBackOverWhatItRead(t *testing.T) {
|
|
||||||
inv := fresh(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
said := aBuild("said", "route-proxy", "")
|
|
||||||
said.Read = []ReadRepository{{Repository: "novox/mesh-controller", Ref: "main"}, {Repository: "novox/other"}}
|
|
||||||
said.Sources = []BuildSource{
|
|
||||||
{Paths: []string{"modules/route-proxy/Dockerfile", "modules/route-proxy/module.json"}},
|
|
||||||
{Repository: "novox/mesh-controller", Ref: "main", Paths: []string{"examples/route-proxy/", "go.mod"}},
|
|
||||||
{Repository: "novox/unread", Paths: []string{"x"}},
|
|
||||||
}
|
|
||||||
unsaid := aBuild("unsaid", "build-agent", "")
|
|
||||||
unsaid.Read = []ReadRepository{{Repository: "novox/mesh-controller", Ref: "main"}}
|
|
||||||
for _, b := range []Build{said, unsaid} {
|
|
||||||
if err := inv.RecordBuild(ctx, b); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
read, err := inv.ReadRepositories(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
want := []ReadRepository{
|
|
||||||
{Repository: "novox/mesh-controller", Ref: "main", Paths: []string{"examples/route-proxy/", "go.mod"}},
|
|
||||||
{Repository: "novox/other"},
|
|
||||||
{Paths: []string{"modules/route-proxy/Dockerfile", "modules/route-proxy/module.json"}, Own: true},
|
|
||||||
}
|
|
||||||
for k := range read["route-proxy"] {
|
|
||||||
if read["route-proxy"][k].Built.IsZero() {
|
|
||||||
t.Errorf("no build time on %+v", read["route-proxy"][k])
|
|
||||||
}
|
|
||||||
read["route-proxy"][k].Built, read["route-proxy"][k].Looked = time.Time{}, time.Time{}
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(read["route-proxy"], want) {
|
|
||||||
t.Fatalf("read back %+v\nwanted %+v", read["route-proxy"], want)
|
|
||||||
}
|
|
||||||
if got := read["build-agent"]; len(got) != 1 || got[0].Paths != nil || got[0].Own {
|
|
||||||
t.Fatalf("a build that said no build source reads as %+v", got)
|
|
||||||
}
|
|
||||||
var stored string
|
|
||||||
if err := inv.store.Pool().QueryRow(ctx, `select built_contexts::text from build where id = 'said'`).Scan(&stored); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if strings.Contains(stored, "paths") || strings.Contains(stored, "own") {
|
|
||||||
t.Fatalf("the build source was stored among what the build read: %s", stored)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A build newer than the newest that worked, and failed, leaves that one's build source stale: its module is
|
|
||||||
// read whole until a build works again (novox/hq ADR 0267).
|
|
||||||
func TestAFailedNewerBuildLeavesTheBuildSourceStale(t *testing.T) {
|
|
||||||
inv := fresh(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
worked := aBuild("worked", "route-proxy", "")
|
|
||||||
worked.Asked = time.Now().Add(-time.Hour)
|
|
||||||
worked.Read = []ReadRepository{{Repository: "novox/mesh-controller", Ref: "main"}}
|
|
||||||
worked.Sources = []BuildSource{{Paths: []string{"modules/route-proxy/module.json"}},
|
|
||||||
{Repository: "novox/mesh-controller", Ref: "main", Paths: []string{"examples/route-proxy/"}}}
|
|
||||||
failed := aBuild("failed", "route-proxy", "compile error")
|
|
||||||
failed.Asked = time.Now()
|
|
||||||
for _, b := range []Build{worked, failed} {
|
|
||||||
if err := inv.RecordBuild(ctx, b); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
read, err := inv.ReadRepositories(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got := read["route-proxy"]; len(got) != 1 || got[0].Paths != nil || got[0].Own || !got[0].Looked.After(got[0].Built) {
|
|
||||||
t.Fatalf("after a newer failed build the proxy reads as %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,10 +13,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
// EdgeStandsOn: the module's artifact is built on the other's.
|
// EdgeStandsOn: the module's artifact is built on the other's.
|
||||||
EdgeStandsOn = "stands-on"
|
EdgeStandsOn = "stands-on"
|
||||||
// EdgePackages: the module's build read the other's repository. **No longer drawn** (novox/hq ADR 0267
|
// EdgePackages: the module's build reads the other's repository.
|
||||||
// rule 4): sharing a repository is no dependency, and a changed file moves every module whose build
|
|
||||||
// source holds it, directly. Kept so an edge recorded or snapshotted before is read, and then neither
|
|
||||||
// widens nor orders a plan.
|
|
||||||
EdgePackages = "packages"
|
EdgePackages = "packages"
|
||||||
// EdgeBuiltBy: the module is built by the holder of the build-machine seat.
|
// EdgeBuiltBy: the module is built by the holder of the build-machine seat.
|
||||||
EdgeBuiltBy = "built-by"
|
EdgeBuiltBy = "built-by"
|
||||||
@@ -45,10 +42,10 @@ type Edge struct {
|
|||||||
// nothing else computes an edge (novox/hq ADR 0162) — the merge handler, `build --on` and the
|
// nothing else computes an edge (novox/hq ADR 0162) — the merge handler, `build --on` and the
|
||||||
// overview all read this.
|
// overview all read this.
|
||||||
//
|
//
|
||||||
// Three sources, one relation: a manifest's `build.on`; the artifacts the latest build was made
|
// Four sources, one relation: a manifest's `build.on`; the artifacts the latest build was made
|
||||||
// against (an `artifact-store://<module>/…` reference is an edge to that module); and the build machine,
|
// against (an `artifact-store://<module>/…` reference is an edge to that module); the repositories
|
||||||
// which every source-built module is built by. The repositories a build read are no edge (novox/hq ADR
|
// the latest build read (an edge to the module whose source that is); and the build machine, which
|
||||||
// 0267 rule 4).
|
// every source-built module is built by.
|
||||||
func (i *Inventory) Dependencies(ctx context.Context) ([]Edge, error) {
|
func (i *Inventory) Dependencies(ctx context.Context) ([]Edge, error) {
|
||||||
entries, err := i.Catalogued(ctx)
|
entries, err := i.Catalogued(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -58,19 +55,24 @@ func (i *Inventory) Dependencies(ctx context.Context) ([]Edge, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return dependenciesOf(entries, against, nil), nil
|
read, err := i.ReadRepositories(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return dependenciesOf(entries, against, read), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// dependenciesOf is Dependencies over what was read, so a test can hand it a catalogue.
|
// dependenciesOf is Dependencies over what was read, so a test can hand it a catalogue.
|
||||||
//
|
func dependenciesOf(entries []Entry, against map[string][]string, read map[string][]ReadRepository) []Edge {
|
||||||
// `read` draws no edge (novox/hq ADR 0267 rule 4): what a build read moves it through its build source, in
|
|
||||||
// the planner, never through the relation.
|
|
||||||
func dependenciesOf(entries []Entry, against map[string][]string, _ map[string][]ReadRepository) []Edge {
|
|
||||||
known := map[string]bool{}
|
known := map[string]bool{}
|
||||||
|
byRepository := map[string][]string{}
|
||||||
var builders []string
|
var builders []string
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
name := e.Manifest.Module
|
name := e.Manifest.Module
|
||||||
known[name] = true
|
known[name] = true
|
||||||
|
if r := repositoryKey(e.Source.Repository); r != "" {
|
||||||
|
byRepository[r] = append(byRepository[r], name)
|
||||||
|
}
|
||||||
if e.Manifest.ClaimsSeat("node-build-agent") || e.Manifest.ClaimsSeat("mesh-build-machine") {
|
if e.Manifest.ClaimsSeat("node-build-agent") || e.Manifest.ClaimsSeat("mesh-build-machine") {
|
||||||
builders = append(builders, name)
|
builders = append(builders, name)
|
||||||
}
|
}
|
||||||
@@ -123,6 +125,11 @@ func dependenciesOf(entries []Entry, against map[string][]string, _ map[string][
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, r := range read[name] {
|
||||||
|
for _, other := range byRepository[repositoryKey(r.Repository)] {
|
||||||
|
add(name, other, EdgePackages)
|
||||||
|
}
|
||||||
|
}
|
||||||
if e.Source.Repository != "" {
|
if e.Source.Repository != "" {
|
||||||
for _, b := range builders {
|
for _, b := range builders {
|
||||||
add(name, b, EdgeBuiltBy)
|
add(name, b, EdgeBuiltBy)
|
||||||
@@ -147,3 +154,8 @@ func dependenciesOf(entries []Entry, against map[string][]string, _ map[string][
|
|||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// repositoryKey is a repository as compared: lower-cased, without a trailing `.git`.
|
||||||
|
func repositoryKey(repository string) string {
|
||||||
|
return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(repository), ".git"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ func TestDependenciesAreOneRelationWithTheirKinds(t *testing.T) {
|
|||||||
{"shop", "mesh-tools", EdgeStandsOn},
|
{"shop", "mesh-tools", EdgeStandsOn},
|
||||||
{"builder", "mesh-tools", EdgeStandsOn},
|
{"builder", "mesh-tools", EdgeStandsOn},
|
||||||
{"shop-plugin", "shop", EdgeDeclared},
|
{"shop-plugin", "shop", EdgeDeclared},
|
||||||
|
{"route-proxy", "mesh-controller", EdgePackages},
|
||||||
{"shop", "builder", EdgeBuiltBy},
|
{"shop", "builder", EdgeBuiltBy},
|
||||||
{"mesh-controller", "builder", EdgeBuiltBy},
|
{"mesh-controller", "builder", EdgeBuiltBy},
|
||||||
{"mesh-tools", "builder", EdgeBuiltBy},
|
{"mesh-tools", "builder", EdgeBuiltBy},
|
||||||
@@ -52,10 +53,6 @@ func TestDependenciesAreOneRelationWithTheirKinds(t *testing.T) {
|
|||||||
t.Errorf("missing %+v in %+v", want, got)
|
t.Errorf("missing %+v in %+v", want, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Sharing a repository is no dependency (novox/hq ADR 0267 rule 4): what a build read draws no edge.
|
|
||||||
if has("route-proxy", "mesh-controller", EdgePackages) {
|
|
||||||
t.Error("a packages edge was drawn from what a build read")
|
|
||||||
}
|
|
||||||
if has("builder", "builder", EdgeBuiltBy) {
|
if has("builder", "builder", EdgeBuiltBy) {
|
||||||
t.Error("the builder is not built by itself")
|
t.Error("the builder is not built by itself")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
-- A build says what it was made from, as files (novox/hq ADR 0267, issue 363).
|
|
||||||
--
|
|
||||||
-- A merge to a repository moved every module whose build read it, whatever the files: the controller,
|
|
||||||
-- built from its repository's root, and the two images whose context is that repository (issue 338).
|
|
||||||
-- A build of a trunk commit now says its build source per repository — a Go program's import closure,
|
|
||||||
-- an archive's directory, an image's recipe — and the planner maps the next merge's changed files onto
|
|
||||||
-- the build source of the module's newest build. Null for a build that said none (one off the trunk,
|
|
||||||
-- one from a builder that predates this, one whose source is not known): its module is read as before.
|
|
||||||
alter table build add column build_sources jsonb;
|
|
||||||
@@ -312,11 +312,6 @@ func (i *Inventory) OpenPlans(ctx context.Context) ([]Plan, error) {
|
|||||||
return i.plans(ctx, `where state in ('building', 'rolling') order by created`)
|
return i.plans(ctx, `where state in ('building', 'rolling') order by created`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlansSince is every plan made after a moment, and every plan still being worked, oldest first.
|
|
||||||
func (i *Inventory) PlansSince(ctx context.Context, since time.Time) ([]Plan, error) {
|
|
||||||
return i.plans(ctx, `where created > $1 or state in ('building', 'rolling') order by created`, since)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecentPlans is the last few plans, newest first, open or not — what the overview shows.
|
// RecentPlans is the last few plans, newest first, open or not — what the overview shows.
|
||||||
func (i *Inventory) RecentPlans(ctx context.Context, limit int) ([]Plan, error) {
|
func (i *Inventory) RecentPlans(ctx context.Context, limit int) ([]Plan, error) {
|
||||||
return i.plans(ctx, fmt.Sprintf(`order by created desc limit %d`, limit))
|
return i.plans(ctx, fmt.Sprintf(`order by created desc limit %d`, limit))
|
||||||
|
|||||||
@@ -224,12 +224,6 @@ type BuildResult struct {
|
|||||||
// manifest the mesh keeps says nothing about it (novox/hq 04-ISSUES/131).
|
// manifest the mesh keeps says nothing about it (novox/hq 04-ISSUES/131).
|
||||||
Read []ReadRepository `json:"read,omitempty"`
|
Read []ReadRepository `json:"read,omitempty"`
|
||||||
|
|
||||||
// Sources are what the build was made from, as files, per repository (novox/hq ADR 0267): the
|
|
||||||
// entries the planner maps the next merge's changed files onto. Said only for a build of a commit on
|
|
||||||
// the trunk, and only for a repository whose every artifact's build source the builder knows; a
|
|
||||||
// repository it says nothing of is read whole, as before. Empty from a builder that predates it.
|
|
||||||
Sources []BuildSource `json:"sources,omitempty"`
|
|
||||||
|
|
||||||
// Trunk is the repository's default branch at the build, and OnTrunk whether the commit built is on it
|
// Trunk is the repository's default branch at the build, and OnTrunk whether the commit built is on it
|
||||||
// (novox/hq ADR 0238): **only a commit on the trunk is published** — the controller refuses to register
|
// (novox/hq ADR 0238): **only a commit on the trunk is published** — the controller refuses to register
|
||||||
// a build of one off it. Empty Trunk is a build seat that could not say, or predates the rule.
|
// a build of one off it. Empty Trunk is a build seat that could not say, or predates the rule.
|
||||||
@@ -269,16 +263,6 @@ type ReadRepository struct {
|
|||||||
Ref string `json:"ref,omitempty"`
|
Ref string `json:"ref,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildSource is the build source a build read in one repository (novox/hq ADR 0267): Repository and Ref
|
|
||||||
// a context's, as its manifest names it, and empty for the module's own; Paths the entries, relative to
|
|
||||||
// that repository's root — `dir/` a Go package's directory but its tests, `dir/**` everything below a
|
|
||||||
// directory, `**` the whole tree, anything else one file (builder.SourceHolds).
|
|
||||||
type BuildSource struct {
|
|
||||||
Repository string `json:"repository,omitempty"`
|
|
||||||
Ref string `json:"ref,omitempty"`
|
|
||||||
Paths []string `json:"paths"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// MadeArtifact is one thing a build produced, as a person would want it reported.
|
// MadeArtifact is one thing a build produced, as a person would want it reported.
|
||||||
type MadeArtifact struct {
|
type MadeArtifact struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|||||||
@@ -122,6 +122,33 @@ func RecordHandAct(ctx context.Context, conn *nats.Conn, act HandAct) (HandAct,
|
|||||||
return act, err
|
return act, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecordHandActOnce writes one entry under the id it carries, only where none is: an act recorded once however
|
||||||
|
// often it is asked, such as a module's act on a warrant, asked by each of its instances (novox/hq ADR 0274). It
|
||||||
|
// answers false, with no error, when the entry was already there.
|
||||||
|
func RecordHandActOnce(ctx context.Context, conn *nats.Conn, act HandAct) (bool, error) {
|
||||||
|
if act.ID == "" || strings.TrimSpace(act.Why) == "" {
|
||||||
|
return false, errors.New("an act recorded once carries its id and why")
|
||||||
|
}
|
||||||
|
if act.At.IsZero() {
|
||||||
|
act.At = time.Now().UTC()
|
||||||
|
}
|
||||||
|
kv, err := handActs(ctx, conn)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(act)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if _, err := kv.Create(ctx, act.ID, body); err != nil {
|
||||||
|
if errors.Is(err, jetstream.ErrKeyExists) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// HandActs is every entry since a moment, oldest first.
|
// HandActs is every entry since a moment, oldest first.
|
||||||
func HandActs(ctx context.Context, conn *nats.Conn, since time.Time) ([]HandAct, error) {
|
func HandActs(ctx context.Context, conn *nats.Conn, since time.Time) ([]HandAct, error) {
|
||||||
kv, err := handActs(ctx, conn)
|
kv, err := handActs(ctx, conn)
|
||||||
|
|||||||
@@ -57,3 +57,30 @@ func TestNatsAnActByHandIsKeptWithWhyAndARepeatIsFound(t *testing.T) {
|
|||||||
t.Fatalf("%q", acts[2].ID)
|
t.Fatalf("%q", acts[2].ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An act on a warrant asked by each of a module's instances is recorded once (novox/hq ADR 0274).
|
||||||
|
func TestNatsAnActRecordedOnceIsWrittenOnce(t *testing.T) {
|
||||||
|
js := aBus(t)
|
||||||
|
api, err := jetstream.New(js.Conn())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = api.DeleteKeyValue(t.Context(), broker.HandActsBucket)
|
||||||
|
if err := js.EnsureControllerBuckets(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
act := HandAct{ID: "warrant-claude-code-instr-1", Verb: "warrant", Why: "the operator chose Approve", By: "the operator"}
|
||||||
|
if _, err := RecordHandActOnce(t.Context(), js.Conn(), HandAct{Verb: "warrant", Why: "x"}); err == nil {
|
||||||
|
t.Fatal("an act without its id was written")
|
||||||
|
}
|
||||||
|
for i, want := range []bool{true, false, false} {
|
||||||
|
written, err := RecordHandActOnce(t.Context(), js.Conn(), act)
|
||||||
|
if err != nil || written != want {
|
||||||
|
t.Fatalf("ask %d: written %v, %v", i, written, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
acts, err := HandActs(t.Context(), js.Conn(), time.Now().Add(-time.Hour))
|
||||||
|
if err != nil || len(acts) != 1 || acts[0].ID != act.ID {
|
||||||
|
t.Fatalf("%v %+v", err, acts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
"resume",
|
"resume",
|
||||||
"hand-act",
|
"hand-act",
|
||||||
"drill",
|
"drill",
|
||||||
|
"warranted",
|
||||||
"hand-acts",
|
"hand-acts",
|
||||||
"durations",
|
"durations",
|
||||||
"conditions",
|
"conditions",
|
||||||
|
|||||||
Reference in New Issue
Block a user