diff --git a/internal/builder/builder.go b/internal/builder/builder.go index 32895a1..a9f7acf 100644 --- a/internal/builder/builder.go +++ b/internal/builder/builder.go @@ -125,16 +125,19 @@ func Build(ctx context.Context, run Runner, publish Publisher, } say("manifest", "%s v%s — %d artifact(s)", manifest.Module, manifest.Version, artifactCount(manifest)) - // A build-time credential, written where a build can mount it but never where it can be copied - // into an image or committed: under the workspace, beside the clone, not inside it. Absent when - // this mesh has no package registry yet, which is the bootstrap case (novox/hq ADR 0076). + // A build-time credential, written into the build context as .npmrc, but ONLY for a module that + // asks for it: a `package` artifact (which publishes), or an image whose Dockerfile COPYs .npmrc. + // Writing it into every context would put a per-run credential in `COPY . .` of modules that + // never resolve a mesh package — making their image non-deterministic (a needless rollout every + // build) and leaking the credential into a build stage. Absent entirely with no registry, which + // is the bootstrap case (novox/hq ADR 0076). var npmrcPath string - if npmrc.Enabled() { + if npmrc.Enabled() && manifest.Build != nil && wantsPackages(manifest, within) { content, err := npmrc.File() if err != nil { return Result{}, err } - npmrcPath = filepath.Join(workspace, "npmrc") + npmrcPath = filepath.Join(within, ".npmrc") if err := os.WriteFile(npmrcPath, []byte(content), 0o600); err != nil { return Result{}, fmt.Errorf("cannot write the package-registry credential for the build: %w", err) } @@ -297,6 +300,24 @@ func against(within string, manifest catalogue.Manifest) []string { // setting somebody has to find. const ManifestName = "module.json" +// wantsPackages reports whether this module's build resolves anything from the mesh's package +// registry, so the credential is written into its context only then. A package artifact always +// does; an image does when its Dockerfile names .npmrc — the file it would COPY to authenticate. +func wantsPackages(manifest catalogue.Manifest, within string) bool { + for _, a := range manifest.Build.Artifacts { + switch a.Kind { + case catalogue.ArtifactPackage: + return true + case catalogue.ArtifactImage: + raw, err := os.ReadFile(filepath.Join(within, a.From)) + if err == nil && strings.Contains(string(raw), ".npmrc") { + return true + } + } + } + return false +} + func one(ctx context.Context, run Runner, publish Publisher, module, tree, commit string, a catalogue.Artifact, args []string, held map[string]string, npmrc string, say func(step, format string, args ...any)) (catalogue.Built, error) { @@ -329,12 +350,10 @@ func one(ctx context.Context, run Runner, publish Publisher, invocation = append(invocation, "--target", a.Target) } if npmrc != "" { - // Given to the build as a buildkit secret, so a RUN that needs the package registry mounts - // it at that step and it is in no image layer. A Dockerfile that does not ask for it is - // unaffected; the secret is simply not read (novox/hq ADR 0076). - invocation = append(invocation, "--secret", "id=npmrc,src="+npmrc) // Host network for the build, so a RUN reaching the package registry finds it where the - // binding says it is — the machine's own loopback, where the registry answers. + // binding says it is — the machine's own loopback, where the registry answers. The + // credential itself is in the context as .npmrc, COPY'd by a stage that is not published; + // buildkit is not required, because this machine's docker may not carry buildx. invocation = append(invocation, "--network", "host") } invocation = append(invocation, ".") diff --git a/internal/builder/packages_test.go b/internal/builder/packages_test.go index a26a287..8a8ae8c 100644 --- a/internal/builder/packages_test.go +++ b/internal/builder/packages_test.go @@ -63,10 +63,12 @@ func TestNpmrcDisabledUntilThereIsARegistry(t *testing.T) { } } -// The credential reaches an image build as a buildkit secret and never as a file inside the build -// context, because a token copied into a layer is a token published (novox/hq ADR 0076). -func TestAnImageBuildGetsTheCredentialAsASecretNotALayer(t *testing.T) { - r, workspace := aRepository(t, withBoth, map[string]string{"Dockerfile": "FROM scratch", "files/x": "y"}) +// The credential reaches an image build as an .npmrc inside the build context — for a Dockerfile to +// COPY in a stage it does not publish — and the build runs on the host network so a RUN resolving the +// registry reaches it where the binding says (novox/hq ADR 0076). Not a buildkit secret, because this +// machine's docker may carry no buildx. +func TestAnImageBuildGetsTheCredentialInTheContextAndHostNetwork(t *testing.T) { + r, workspace := aRepository(t, withBoth, map[string]string{"Dockerfile": "FROM scratch\nCOPY .npmrc ./", "files/x": "y"}) n := Npmrc{Scope: "@novox", Registry: "https://forge.invalid/api/packages/novox/npm/", Token: "t"} if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/meshboard.git", "", "", workspace, nil, n, nil); err != nil { @@ -82,33 +84,35 @@ func TestAnImageBuildGetsTheCredentialAsASecretNotALayer(t *testing.T) { if build == "" { t.Fatal("no docker build ran") } - if !strings.Contains(build, "--secret id=npmrc,src=") { - t.Fatalf("the build was not given the credential as a secret: %s", build) + if strings.Contains(build, "--secret") { + t.Fatalf("the build used a buildkit secret, which this path avoids: %s", build) } - - // The .npmrc lives under the workspace, beside the clone, never inside the source tree that is - // the docker context. + if !strings.Contains(build, "--network host") { + t.Fatalf("the build was not given the host network to reach the registry: %s", build) + } + // The .npmrc is written into the build context (the source tree), where a Dockerfile COPYs it. tree := filepath.Join(workspace, "source") - src := strings.SplitN(strings.SplitN(build, "--secret id=npmrc,src=", 2)[1], " ", 2)[0] - if strings.HasPrefix(src, tree+string(os.PathSeparator)) { - t.Fatalf("the credential file %s is inside the build context %s", src, tree) - } - if _, err := os.Stat(src); err != nil { - t.Fatalf("the credential file the build was pointed at does not exist: %v", err) + npmrc := filepath.Join(tree, ".npmrc") + if _, err := os.Stat(npmrc); err != nil { + t.Fatalf("the credential was not written into the build context: %v", err) } } -func TestAnImageBuildWithoutACredentialGetsNoSecret(t *testing.T) { +func TestAnImageBuildWithoutACredentialGetsNoHostNetwork(t *testing.T) { r, workspace := aRepository(t, withBoth, map[string]string{"Dockerfile": "FROM scratch", "files/x": "y"}) if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/meshboard.git", "", "", workspace, nil, Npmrc{}, nil); err != nil { t.Fatalf("the build failed: %v", err) } for _, line := range r.ran { - if strings.HasPrefix(line, "docker build") && strings.Contains(line, "--secret") { - t.Fatalf("a build with no credential was still given a secret: %s", line) + if strings.HasPrefix(line, "docker build") && (strings.Contains(line, "--secret") || strings.Contains(line, "--network host")) { + t.Fatalf("a build with no credential was still given build-network or a secret: %s", line) } } + tree := filepath.Join(workspace, "source") + if _, err := os.Stat(filepath.Join(tree, ".npmrc")); err == nil { + t.Fatal("an .npmrc was written into a build that has no credential") + } } const aPackage = `{"module":"mesh-sdk","version":"1", @@ -194,3 +198,23 @@ func TestNpmrcRefusesWhenGivenNeitherTokenNorPassword(t *testing.T) { t.Fatal("a username with no password rendered an .npmrc") } } + +func TestAnImageThatDoesNotAskForTheCredentialDoesNotGetIt(t *testing.T) { + // A Dockerfile with no .npmrc reference (like the control plane's) must build clean: no .npmrc + // in its context, no host network — so its image stays deterministic and the credential does not + // leak into a build that never resolves a mesh package. + r, workspace := aRepository(t, withBoth, map[string]string{"Dockerfile": "FROM scratch\nCOPY . .", "files/x": "y"}) + n := Npmrc{Scope: "@novox", Registry: "https://forge.invalid/api/packages/novox/npm/", Token: "t"} + if _, err := Build(context.Background(), r.run, r, + "https://forge.invalid/meshboard.git", "", "", workspace, nil, n, nil); err != nil { + t.Fatalf("the build failed: %v", err) + } + for _, line := range r.ran { + if strings.HasPrefix(line, "docker build") && strings.Contains(line, "--network host") { + t.Fatalf("a build that does not ask for the credential got the host network: %s", line) + } + } + if _, err := os.Stat(filepath.Join(workspace, "source", ".npmrc")); err == nil { + t.Fatal("an .npmrc was written into a build that does not reference it") + } +}