diff --git a/cmd/mesh-builder/main.go b/cmd/mesh-builder/main.go index bb68faa..2ca09b0 100644 --- a/cmd/mesh-builder/main.go +++ b/cmd/mesh-builder/main.go @@ -139,7 +139,7 @@ func run() error { return err } - fmt.Printf("building for the mesh, publishing to %s\n", registry) + fmt.Fprintf(os.Stderr, "building for the mesh, publishing to %s\n", registry) publisher := builder.Registry{Address: registry, Run: builder.Command} for { @@ -164,14 +164,14 @@ func answer(ctx context.Context, channel *amqp.Channel, publisher builder.Publis // until it either finishes or fails is indistinguishable from one that never arrived — which // cost a long diagnosis against a running mesh, chasing "the handler never fired" when the // truth was only that the handler said nothing until the end. - fmt.Printf("a build request arrived (%d bytes)\n", len(delivery.Body)) + fmt.Fprintf(os.Stderr, "a build request arrived (%d bytes)\n", len(delivery.Body)) var request link.BuildRequest if err := json.Unmarshal(delivery.Body, &request); err != nil { // Unreadable. Acknowledged and dropped rather than requeued: a message this builder // cannot parse will not become parseable by being delivered again, and requeueing it // would put it in front of every real request for ever. - fmt.Printf("a request could not be read and was dropped: %v\n", err) + fmt.Fprintf(os.Stderr, "a request could not be read and was dropped: %v\n", err) _ = delivery.Ack(false) return } @@ -180,19 +180,19 @@ func answer(ctx context.Context, channel *amqp.Channel, publisher builder.Publis ID: request.ID, Repository: request.Repository, Path: request.Path, Ref: request.Ref, On: on, } - fmt.Printf("building %s", request.Repository) + fmt.Fprintf(os.Stderr, "building %s", request.Repository) if request.Path != "" { - fmt.Printf(" at %s", request.Path) + fmt.Fprintf(os.Stderr, " at %s", request.Path) } if request.Ref != "" { - fmt.Printf(" at %s", request.Ref) + fmt.Fprintf(os.Stderr, " at %s", request.Ref) } - fmt.Println() + fmt.Fprintln(os.Stderr) built, err := builder.Build(ctx, builder.Command, publisher, request.Repository, request.Path, request.Ref, workspace, request.Held, func(step, message string) { - fmt.Printf(" [%s] %s\n", step, message) + fmt.Fprintf(os.Stderr, " [%s] %s\n", step, message) }) if err != nil { // A failure is a result. A build that fails and says nothing is indistinguishable from a @@ -212,7 +212,7 @@ func answer(ctx context.Context, channel *amqp.Channel, publisher builder.Publis }) } result.Against = built.Against - fmt.Printf(" built %s from %s\n", built.Manifest.Module, short(built.Commit)) + fmt.Fprintf(os.Stderr, " built %s from %s\n", built.Manifest.Module, short(built.Commit)) } } diff --git a/cmd/mesh-builder/once.go b/cmd/mesh-builder/once.go index 953cf47..e6f7196 100644 --- a/cmd/mesh-builder/once.go +++ b/cmd/mesh-builder/once.go @@ -85,7 +85,7 @@ func buildOnce(ctx context.Context, args []string) error { fmt.Fprintln(os.Stderr) built, buildErr := builder.Build(ctx, builder.Command, publisher, repository, *path, *ref, where, bases, - func(step, message string) { fmt.Printf(" [%s] %s\n", step, message) }) + func(step, message string) { fmt.Fprintf(os.Stderr, " [%s] %s\n", step, message) }) if buildErr != nil { return buildErr } diff --git a/cmd/mesh-builder/stdout_test.go b/cmd/mesh-builder/stdout_test.go new file mode 100644 index 0000000..1d1bfd5 --- /dev/null +++ b/cmd/mesh-builder/stdout_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +// **The genesis path writes its result to stdout and nothing else there.** The installer captures +// stdout alone and parses it as JSON; one stray log line makes that fail with "invalid character" +// — exactly what a builder fmt.Printf caused, breaking a build that had worked. This keeps +// diagnostics on stderr so a future print cannot repeat it silently. +func TestBuilderDiagnosticsStayOffStdout(t *testing.T) { + allowed := map[string]bool{ + "string(body)": true, // once.go: the result JSON, which IS stdout + "version)": true, // --version + `"stopping")`: true, // the loop.s shutdown line + "usage)": true, // --help text, for a human + } + for _, file := range []string{"once.go", "main.go"} { + src, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + for i, raw := range strings.Split(string(src), "\n") { + line := strings.TrimSpace(raw) + if !strings.HasPrefix(line, "fmt.Printf(") && + !strings.HasPrefix(line, "fmt.Println(") && + !strings.HasPrefix(line, "fmt.Print(") { + continue + } + ok := false + for token := range allowed { + if strings.Contains(line, token) { + ok = true + } + } + if !ok { + t.Errorf("%s:%d writes a diagnostic to stdout, which the installer parses as JSON:\n %s", + file, i+1, line) + } + } + } +} diff --git a/internal/builder/builder.go b/internal/builder/builder.go index d950905..54342ed 100644 --- a/internal/builder/builder.go +++ b/internal/builder/builder.go @@ -478,16 +478,16 @@ func Command(ctx context.Context, dir, name string, args ...string) (string, err // nothing" and "git clone is waiting on a network that will not answer". Silent on success is // what made an empty workspace unreadable. started := timeNow() - fmt.Printf(" $ (%s) %s %s\n", short(filepath.Base(dir)), name, strings.Join(args, " ")) + fmt.Fprintf(os.Stderr, " $ (%s) %s %s\n", short(filepath.Base(dir)), name, strings.Join(args, " ")) cmd := exec.CommandContext(ctx, name, args...) cmd.Dir = dir out, err := cmd.CombinedOutput() if err != nil { - fmt.Printf(" ! %s %s failed after %s\n", name, args[0], since(started)) + fmt.Fprintf(os.Stderr, " ! %s %s failed after %s\n", name, args[0], since(started)) return string(out), fmt.Errorf("%s %s: %w\n%s", name, strings.Join(args, " "), err, strings.TrimSpace(string(out))) } - fmt.Printf(" ✓ %s %s (%s)\n", name, firstArg(args), since(started)) + fmt.Fprintf(os.Stderr, " ✓ %s %s (%s)\n", name, firstArg(args), since(started)) return string(out), nil }