Build a module from a repository and a path within it

The builder cloned a repository and read the manifest at its root, which means one
repository per module. Nothing we have is shaped that way, so the builder could be
asked to build nothing that exists (novox/hq ADR 0069).

The path travels the whole way — named when asking, carried in the request, used
to read the manifest and as the context everything is produced from, echoed back
in the result, and recorded as part of where a module came from. Without that last
part the mesh could notice a module was behind its source and then be unable to
rebuild it, which is the worst of both.

A path climbing out of the clone is refused: a machine whose job is building other
people's repositories must not read whatever else is on its disk.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
This commit is contained in:
2026-09-12 16:45:50 +02:00
parent c4030947b0
commit f151de103f
7 changed files with 160 additions and 33 deletions
+6 -2
View File
@@ -152,16 +152,20 @@ func answer(ctx context.Context, channel *amqp.Channel, publisher builder.Publis
}
result := link.BuildResult{
ID: request.ID, Repository: request.Repository, Ref: request.Ref, On: on,
ID: request.ID, Repository: request.Repository, Path: request.Path,
Ref: request.Ref, On: on,
}
fmt.Printf("building %s", request.Repository)
if request.Path != "" {
fmt.Printf(" at %s", request.Path)
}
if request.Ref != "" {
fmt.Printf(" at %s", request.Ref)
}
fmt.Println()
built, err := builder.Build(ctx, builder.Command, publisher,
request.Repository, request.Ref, workspace)
request.Repository, request.Path, request.Ref, workspace)
if err != nil {
// A failure is a result. A build that fails and says nothing is indistinguishable from a
// builder that is not running, and those want completely different responses.
+16 -8
View File
@@ -33,6 +33,9 @@ import (
func buildCommand(ctx context.Context, args []string) error {
set := flag.NewFlagSet("build", flag.ContinueOnError)
ref := set.String("ref", "", "the branch, tag or commit to build")
// A module is a repository and a path within it (novox/hq ADR 0069). Empty is the repository's
// root, which is the ordinary case and why this is a flag rather than a second argument.
path := set.String("path", "", "the module's directory inside the repository")
wait := set.Duration("wait", 10*time.Minute, "how long to wait for a builder to answer")
dryRun := set.Bool("dry-run", false, "build and print the manifest, recording nothing")
// Every module whose source has moved, rather than one named repository.
@@ -58,9 +61,9 @@ func buildCommand(ctx context.Context, args []string) error {
}
if *dryRun {
return buildAndShow(ctx, positionals[0], *ref, *wait)
return buildAndShow(ctx, positionals[0], *path, *ref, *wait)
}
return buildOne(ctx, positionals[0], *ref, *wait)
return buildOne(ctx, positionals[0], *path, *ref, *wait)
}
// buildFrom turns what a builder said into what the mesh keeps.
@@ -300,7 +303,7 @@ func buildBehind(ctx context.Context, wait time.Duration) error {
// Its own recorded ref, not its head commit: a module tracking a branch should be built
// from that branch, and pinning to the commit the mesh happened to notice would quietly
// turn a tracked branch into a pin.
if err := buildOne(ctx, e.Source.Repository, e.Source.Ref, wait); err != nil {
if err := buildOne(ctx, e.Source.Repository, e.Source.Path, e.Source.Ref, wait); err != nil {
fmt.Printf(" %v\n", err)
failed = append(failed, e.Manifest.Module)
}
@@ -318,7 +321,7 @@ func buildBehind(ctx context.Context, wait time.Duration) error {
// buildOne asks a build machine for one repository and records everything that came back.
//
// Separated from the command so `--behind` can walk a list without a second path to the same act.
func buildOne(ctx context.Context, repository, ref string, wait time.Duration) error {
func buildOne(ctx context.Context, repository, path, ref string, wait time.Duration) error {
ident, err := openIdentity(ctx)
if err != nil {
return err
@@ -336,11 +339,15 @@ func buildOne(ctx context.Context, repository, ref string, wait time.Duration) e
request := link.BuildRequest{
ID: fmt.Sprintf("%s-%d", "build", time.Now().UnixNano()),
Repository: repository,
Path: path,
Ref: ref,
}
fmt.Printf("asked for %s", request.Repository)
if path != "" {
fmt.Printf(" at %s", path)
}
if ref != "" {
fmt.Printf(" at %s", ref)
fmt.Printf(" on %s", ref)
}
fmt.Println()
@@ -383,7 +390,7 @@ func buildOne(ctx context.Context, repository, ref string, wait time.Duration) e
// Recorded with where it came from, so "is this current?" is answerable without building it
// again (novox/hq ADR 0009).
if err := inv.RegisterModule(ctx, manifest, inventory.Source{
Repository: result.Repository, Ref: result.Ref,
Repository: result.Repository, Path: result.Path, Ref: result.Ref,
BuiltFrom: result.Commit, Head: result.Commit,
}); err != nil {
return err
@@ -395,7 +402,7 @@ func buildOne(ctx context.Context, repository, ref string, wait time.Duration) e
}
// buildAndShow builds and prints the manifest without recording anything.
func buildAndShow(ctx context.Context, repository, ref string, wait time.Duration) error {
func buildAndShow(ctx context.Context, repository, path, ref string, wait time.Duration) error {
ident, err := openIdentity(ctx)
if err != nil {
return err
@@ -408,7 +415,8 @@ func buildAndShow(ctx context.Context, repository, ref string, wait time.Duratio
defer server.Close()
result, err := link.RequestBuild(ctx, server.Channel(), link.BuildRequest{
ID: fmt.Sprintf("%s-%d", "build", time.Now().UnixNano()), Repository: repository, Ref: ref,
ID: fmt.Sprintf("%s-%d", "build", time.Now().UnixNano()),
Repository: repository, Path: path, Ref: ref,
}, wait)
if err != nil {
return err