diff --git a/.claude/skills/verify-changes/SKILL.md b/.claude/skills/verify-changes/SKILL.md index 0952eb2..1c6d601 100644 --- a/.claude/skills/verify-changes/SKILL.md +++ b/.claude/skills/verify-changes/SKILL.md @@ -23,6 +23,24 @@ go test ./... `go mod tidy -diff` is the one people miss: a dependency added by hand passes build and test, then fails CI. +## Next packaging + +Changes to declared input projection or standalone packaging also run: + +```bash +go test -run 'TestDeclared|TestBuildRecipe|TestRecipeBuild' -count=1 . +CODEFLY_TEST_NEXT_BUILD=1 go test -run '^TestNext(ConfiguredStandaloneOutput|ExportRejectsStaleOutput)$' -count=1 -timeout=10m -v . +``` + +The latter installs exact Next/React fixture dependencies in temporary directories +and runs real Next production builds for default and custom/nested output, plus +default/custom export freshness refusal tests using the real config loader. It +requires Node/npm and package-registry access. The optional +`CODEFLY_NEXT_CONSUMER_SOURCE` fixture checks an explicitly selected consumer +snapshot without writing into it. Neither establishes image execution: the +maintained CLI must build the actual recipes, and image health/page/static asset +checks must run without host source or node_modules before image qualification. + ## The three gates the plain suite does not cover ### Native effective-input discovery diff --git a/build_context.go b/build_context.go new file mode 100644 index 0000000..fb9de7d --- /dev/null +++ b/build_context.go @@ -0,0 +1,315 @@ +package main + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/codefly-dev/core/shared" + "github.com/moby/patternmatcher" + "github.com/moby/patternmatcher/ignorefile" +) + +// npmBuildInputs admits only package roots and archives named by the npm graph. +// The common ancestor supplies coordinates; it is never itself a copy request. +type npmBuildInputs struct { + roots, archives, installs []string + common string +} + +func declaredBuildInputs(app string) (*npmBuildInputs, error) { + inputs := &npmBuildInputs{common: app} + state := map[string]int{} + var visit func(string, bool) error + visit = func(root string, installedByParent bool) error { + if state[root] == 1 { + return fmt.Errorf("local npm dependency cycle at %s", root) + } + if state[root] == 2 { + return nil + } + state[root] = 1 + manifest, err := readNodePackageManifest(root) + if err != nil { + return err + } + if manifest == nil { + return fmt.Errorf("missing package.json: %s", root) + } + lock, err := readNodePackageLock(root) + if err != nil { + return err + } + if lock == nil && (root == app || (!installedByParent && len(manifest.Dependencies)+len(manifest.DevDependencies)+len(manifest.OptionalDependencies) > 0)) { + return fmt.Errorf("package with install dependencies requires package-lock.json: %s", root) + } + refs := map[string]bool{} + for _, deps := range []map[string]string{manifest.Dependencies, manifest.DevDependencies, manifest.OptionalDependencies, manifest.PeerDependencies} { + for _, ref := range deps { + switch { + case strings.HasPrefix(ref, "file:"): + refs[strings.TrimPrefix(ref, "file:")] = true + case filepath.IsAbs(ref), strings.HasPrefix(ref, "."), strings.HasPrefix(ref, "link:"), strings.HasPrefix(ref, "workspace:"): + return fmt.Errorf("unsupported local npm reference %q in %s; use relative file: declarations", ref, root) + } + } + } + if lock != nil { + for _, entry := range lock.Packages { + if entry.Link || strings.HasPrefix(entry.Resolved, "file:") { + refs[strings.TrimPrefix(entry.Resolved, "file:")] = true + } + } + } + names := make([]string, 0, len(refs)) + for name := range refs { + names = append(names, name) + } + sort.Strings(names) + for _, ref := range names { + if ref == "" || filepath.IsAbs(ref) || strings.ContainsAny(ref, "\\:\x00") { + return fmt.Errorf("unsupported local npm path %q in %s", ref, root) + } + target := filepath.Clean(filepath.Join(root, filepath.FromSlash(ref))) + physical, err := filepath.EvalSymlinks(target) + if err != nil { + return fmt.Errorf("npm input %s: %w", target, err) + } + if physical != target { + return fmt.Errorf("symlink npm input refused: %s", target) + } + info, err := os.Stat(target) + if err != nil { + return err + } + if info.IsDir() { + if err := visit(target, lock != nil && containedIn(root, target)); err != nil { + return err + } + } else if info.Mode().IsRegular() && (strings.HasSuffix(target, ".tgz") || strings.HasSuffix(target, ".tar.gz")) { + inputs.archives = append(inputs.archives, target) + } else { + return fmt.Errorf("npm input is not a package directory or archive: %s", target) + } + } + state[root] = 2 + inputs.roots = append(inputs.roots, root) + if lock != nil { + inputs.installs = append(inputs.installs, root) + } + return nil + } + if err := visit(app, false); err != nil { + return nil, err + } + for _, input := range append(append([]string{}, inputs.roots...), inputs.archives...) { + for !containedIn(inputs.common, input) { + inputs.common = filepath.Dir(inputs.common) + } + } + if inputs.common == string(filepath.Separator) { + return nil, fmt.Errorf("npm inputs have no bounded common directory") + } + return inputs, nil +} + +// Each declared root keeps its Docker ignore policy. Matching is the same +// library used by Core's PrepareBuildContext; negations in one policy cannot +// override exclusions in another. Ancestor policy still applies to child roots. +type buildIgnore struct { + root string + matcher *patternmatcher.PatternMatcher +} + +func inputIgnorePolicies(roots []string) ([]buildIgnore, error) { + var policies []buildIgnore + for _, root := range roots { + file, err := os.Open(filepath.Join(root, ".dockerignore")) + if os.IsNotExist(err) { + continue + } + if err != nil { + return nil, err + } + patterns, err := ignorefile.ReadAll(file) + closeErr := file.Close() + if err != nil { + return nil, err + } + if closeErr != nil { + return nil, closeErr + } + matcher, err := patternmatcher.New(patterns) + if err != nil { + return nil, err + } + policies = append(policies, buildIgnore{root, matcher}) + } + return policies, nil +} + +const contextOwner = "codefly-next-declared-inputs-v1\n" + +func stageBuildInputs(ctx context.Context, app, service, output string) (*npmBuildInputs, string, error) { + relative, err := filepath.Rel(service, app) + if err != nil || !filepath.IsLocal(relative) { + return nil, "", fmt.Errorf("application must stay inside service") + } + service, err = filepath.EvalSymlinks(service) + if err != nil { + return nil, "", err + } + expected := filepath.Join(service, relative) + app, err = filepath.EvalSymlinks(expected) + if err != nil { + return nil, "", err + } + if app != expected { + return nil, "", fmt.Errorf("symlink application source refused: %s", expected) + } + inputs, err := declaredBuildInputs(app) + if err != nil { + return nil, "", err + } + output, err = filepath.Abs(output) + if err != nil { + return nil, "", err + } + output = physicalTarget(output) + for _, input := range append(append([]string{service}, inputs.roots...), inputs.archives...) { + if containedIn(output, input) { + return nil, "", fmt.Errorf("output directory contains source input: %s", input) + } + } + for _, root := range inputs.roots { + relative, _ := filepath.Rel(inputs.common, root) + if strings.ContainsAny(relative, "$\\\n\r\x00") { + return nil, "", fmt.Errorf("unsupported Docker workdir characters in %q", relative) + } + } + destination := filepath.Join(output, "context") + if info, statErr := os.Lstat(destination); statErr == nil { + marker := filepath.Join(destination, ".codefly-owner") + mi, me := os.Lstat(marker) + data, re := os.ReadFile(marker) + if !info.IsDir() || me != nil || !mi.Mode().IsRegular() || re != nil || string(data) != contextOwner { + return nil, "", fmt.Errorf("refuse unowned generated context: %s", destination) + } + } else if !os.IsNotExist(statErr) { + return nil, "", statErr + } + policies, err := inputIgnorePolicies(append([]string{service}, inputs.roots...)) + if err != nil { + return nil, "", err + } + if err = os.MkdirAll(output, 0755); err != nil { + return nil, "", err + } + staged, err := os.MkdirTemp(output, ".context-") + if err != nil { + return nil, "", err + } + defer os.RemoveAll(staged) + copyInput := func(file string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if err := ctx.Err(); err != nil { + return err + } + if containedIn(output, file) { + if entry.IsDir() { + return filepath.SkipDir + } + return nil + } + switch entry.Name() { + case "node_modules", ".next", ".git", ".cache": + if entry.IsDir() { + return filepath.SkipDir + } + return nil + case "tsconfig.tsbuildinfo": + return nil + } + // Recognize host build artifacts only for exclusion, never output selection. + if entry.IsDir() { + if _, err := os.Lstat(filepath.Join(file, "required-server-files.json")); err == nil { + return filepath.SkipDir + } + } + for _, policy := range policies { + if containedIn(policy.root, file) && file != policy.root { + rel, _ := filepath.Rel(policy.root, file) + excluded, err := policy.matcher.MatchesOrParentMatches(filepath.ToSlash(rel)) + if err != nil { + return err + } + if excluded { + return nil + } + } + } + relative, _ := filepath.Rel(inputs.common, file) + target := filepath.Join(staged, "src", relative) + if entry.IsDir() { + return os.MkdirAll(target, 0755) + } + info, err := entry.Info() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("nonregular npm build input refused: %s", file) + } + if err = shared.CopyFile(ctx, file, target); err != nil { + return err + } + return os.Chmod(target, info.Mode().Perm()) + } + for _, root := range inputs.roots { + if err = filepath.WalkDir(root, copyInput); err != nil { + return nil, "", err + } + } + for _, archive := range inputs.archives { + if err = filepath.WalkDir(archive, copyInput); err != nil { + return nil, "", err + } + } + // Ignore policies may not silently discard an admitted install input. + for _, root := range inputs.roots { + rel, _ := filepath.Rel(inputs.common, root) + for _, name := range []string{"package.json", "package-lock.json", ".npmrc"} { + if _, err := os.Stat(filepath.Join(root, name)); err == nil { + if _, err = os.Stat(filepath.Join(staged, "src", rel, name)); err != nil { + return nil, "", fmt.Errorf("ignored required npm input %s: %w", filepath.Join(root, name), err) + } + } + } + } + for _, archive := range inputs.archives { + rel, _ := filepath.Rel(inputs.common, archive) + if _, err = os.Stat(filepath.Join(staged, "src", rel)); err != nil { + return nil, "", fmt.Errorf("ignored npm archive %s", archive) + } + } + if err = shared.CopyFile(ctx, filepath.Join(service, "service.codefly.yaml"), filepath.Join(staged, "service.codefly.yaml")); err != nil { + return nil, "", err + } + if err = os.WriteFile(filepath.Join(staged, ".codefly-owner"), []byte(contextOwner), 0644); err != nil { + return nil, "", err + } + if err = os.RemoveAll(destination); err != nil { + return nil, "", err + } + if err = os.Rename(staged, destination); err != nil { + return nil, "", err + } + relative, _ = filepath.Rel(inputs.common, app) + return inputs, filepath.ToSlash(relative), nil +} diff --git a/build_context_test.go b/build_context_test.go new file mode 100644 index 0000000..c835d6d --- /dev/null +++ b/build_context_test.go @@ -0,0 +1,187 @@ +package main + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/codefly-dev/core/agents/services" + builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0" + "github.com/stretchr/testify/require" +) + +func buildInputWrite(t *testing.T, root, name, data string) { + t.Helper() + target := filepath.Join(root, name) + require.NoError(t, os.MkdirAll(filepath.Dir(target), 0755)) + require.NoError(t, os.WriteFile(target, []byte(data), 0644)) +} +func buildInputGraph(t *testing.T) (string, string) { + t.Helper() + root := physicalPath(t.TempDir()) + app := filepath.Join(root, "parent", "services", "web") + writeRecipePackage(t, app) + writeRecipePackage(t, filepath.Join(root, "parent")) + writeRecipePackage(t, filepath.Join(root, "remote")) + buildInputWrite(t, app, "service.codefly.yaml", "name: web\n") + buildInputWrite(t, app, "package.json", `{"name":"web","dependencies":{"parent":"file:../..","remote":"file:../../../remote","archive":"file:../../../remote/vendor/kit.tgz"}}`) + buildInputWrite(t, root, "parent/package.json", `{"name":"parent","devDependencies":{"leaf":"file:../leaf"}}`) + buildInputWrite(t, root, "leaf/package.json", `{"name":"leaf","version":"1.0.0","peerDependencies":{"react":"*"}}`) + buildInputWrite(t, root, "remote/vendor/kit.tgz", "archive bytes") + buildInputWrite(t, root, "remote/scripts/check.mjs", "source script") + buildInputWrite(t, root, "unrelated/sentinel", "must not project") + buildInputWrite(t, app, "node_modules/ambient/index.js", "host install") + buildInputWrite(t, app, ".next/dev-sentinel", "host dev") + buildInputWrite(t, app, ".next-build/required-server-files.json", `{"stale":true}`) + buildInputWrite(t, app, ".next-build/standalone/server.js", "host production") + buildInputWrite(t, app, ".dockerignore", "private\n!private/keep\n") + buildInputWrite(t, app, "private/token", "excluded") + buildInputWrite(t, app, "private/keep", "included") + return root, app +} +func TestDeclaredBuildProjection(t *testing.T) { + root, app := buildInputGraph(t) + output := filepath.Join(root, "parent", "builder") + buildInputWrite(t, output, "caller.txt", "preserved") + inputs, relative, err := stageBuildInputs(t.Context(), app, app, output) + require.NoError(t, err) + require.Equal(t, "parent/services/web", relative) + require.Equal(t, []string{filepath.Join(root, "parent"), filepath.Join(root, "remote"), app}, inputs.installs) + for _, name := range []string{"parent/package.json", "parent/services/web/package.json", "remote/scripts/check.mjs", "remote/vendor/kit.tgz", "leaf/package.json", "parent/services/web/private/keep"} { + require.FileExists(t, filepath.Join(output, "context", "src", name)) + } + for _, name := range []string{"unrelated", "parent/builder", "parent/services/web/node_modules", "parent/services/web/.next", "parent/services/web/.next-build", "parent/services/web/private/token"} { + require.NoFileExists(t, filepath.Join(output, "context", "src", name)) + require.NoDirExists(t, filepath.Join(output, "context", "src", name)) + } + before, err := os.ReadFile(filepath.Join(app, "package.json")) + require.NoError(t, err) + _, _, err = stageBuildInputs(t.Context(), app, app, output) + require.NoError(t, err) + after, err := os.ReadFile(filepath.Join(app, "package.json")) + require.NoError(t, err) + require.Equal(t, before, after) + require.FileExists(t, filepath.Join(output, "caller.txt")) + buildInputWrite(t, output, "Dockerfile", "FROM scratch\nCOPY src /src\n") + plan, err := services.BuildDockerBuildPlan(output, []*builderv0.DockerBuildRecipe{{Name: "web", Image: "example/web:1", Dockerfile: "Dockerfile", Context: "context", ContextRoot: builderv0.RecipeContextRoot_RECIPE_CONTEXT_ROOT_OUTPUT}}) + require.NoError(t, err) + require.NoError(t, services.VerifyDockerBuildPlan(output, plan)) + buildInputWrite(t, output, "context/src/injected", "extra") + require.Error(t, services.VerifyDockerBuildPlan(output, plan)) +} +func TestDeclaredBuildProjectionRefusals(t *testing.T) { + for _, scenario := range []string{"cycle", "missing", "absolute", "symlink", "app symlink", "source output", "ancestor output", "collision", "ignored manifest", "missing lock"} { + t.Run(scenario, func(t *testing.T) { + root, app := buildInputGraph(t) + output := filepath.Join(root, "output") + switch scenario { + case "cycle": + buildInputWrite(t, root, "leaf/package.json", `{"name":"leaf","dependencies":{"web":"file:../parent/services/web"}}`) + writeRecipePackage(t, filepath.Join(root, "leaf")) + buildInputWrite(t, root, "leaf/package.json", `{"name":"leaf","dependencies":{"web":"file:../parent/services/web"}}`) + case "missing": + require.NoError(t, os.Remove(filepath.Join(root, "remote/vendor/kit.tgz"))) + case "absolute": + data, _ := json.Marshal(map[string]any{"dependencies": map[string]string{"leaf": "file:" + filepath.Join(root, "leaf")}}) + buildInputWrite(t, app, "package.json", string(data)) + case "symlink": + require.NoError(t, os.Symlink(filepath.Join(root, "leaf/package.json"), filepath.Join(app, "escape"))) + case "app symlink": + alias := filepath.Join(root, "parent", "services", "alias") + require.NoError(t, os.Symlink(app, alias)) + app = alias + case "source output": + output = app + case "ancestor output": + output = root + case "collision": + buildInputWrite(t, output, "context/unowned", "preserve") + case "ignored manifest": + buildInputWrite(t, app, ".dockerignore", "package.json\n") + case "missing lock": + require.NoError(t, os.Remove(filepath.Join(root, "parent/package-lock.json"))) + } + service := app + if scenario == "app symlink" { + service = filepath.Join(root, "parent", "services") + } + _, _, err := stageBuildInputs(t.Context(), app, service, output) + require.Error(t, err) + require.FileExists(t, filepath.Join(root, "parent/services/web/package.json")) + }) + } +} + +// Real consumer graph transport, opt-in only because the source fixture is +// supplied externally. It does not install into or modify that source. +func TestSelectedConsumerBuildProjection(t *testing.T) { + root := os.Getenv("CODEFLY_NEXT_CONSUMER_SOURCE") + if root == "" { + t.Skip("requires exact selected consumer source") + } + for _, solution := range []string{"wiki", "proposals"} { + t.Run(solution, func(t *testing.T) { + app := filepath.Join(root, "solutions", solution, "services", "web") + output := t.TempDir() + inputs, relative, err := stageBuildInputs(t.Context(), app, app, output) + require.NoError(t, err) + require.Equal(t, "solutions/"+solution+"/services/web", relative) + require.FileExists(t, filepath.Join(output, "context/src/packages/solution-remote/package.json")) + require.NoDirExists(t, filepath.Join(output, "context/src/solutions", solution, "services/web/.next-build")) + require.NoDirExists(t, filepath.Join(output, "context/src/solutions/wiki/services/backend")) + require.Equal(t, app, inputs.installs[len(inputs.installs)-1]) + t.Logf("app %s installs %v", relative, inputs.installs) + }) + } +} + +func TestDeclaredNPMInstallOrder(t *testing.T) { + if _, err := exec.LookPath("npm"); err != nil { + t.Skip("npm unavailable") + } + root := physicalPath(t.TempDir()) + app := filepath.Join(root, "parent", "web") + buildInputWrite(t, root, "leaf/package.json", `{"name":"leaf","version":"1.0.0","main":"index.js"}`) + buildInputWrite(t, root, "leaf/index.js", `module.exports = 42`) + buildInputWrite(t, root, "parent/package.json", `{"name":"parent","version":"1.0.0","main":"index.js","dependencies":{"leaf":"file:../leaf","kit":"file:../vendor/kit-1.0.0.tgz"}}`) + buildInputWrite(t, root, "parent/index.js", `module.exports = require('leaf')`) + buildInputWrite(t, root, "parent/.npmrc", "install-links=true\n") + buildInputWrite(t, root, "kit/package.json", `{"name":"kit","version":"1.0.0","main":"index.js"}`) + buildInputWrite(t, root, "kit/index.js", `module.exports = 7`) + require.NoError(t, os.MkdirAll(filepath.Join(root, "vendor"), 0755)) + buildInputWrite(t, app, "package.json", `{"name":"web","version":"1.0.0","dependencies":{"parent":"file:.."}}`) + buildInputWrite(t, app, ".npmrc", "install-links=false\n") + buildInputWrite(t, app, "service.codefly.yaml", "name: web\n") + run := func(dir string, args ...string) { + cmd := exec.CommandContext(t.Context(), args[0], args[1:]...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "npm_config_cache="+filepath.Join(root, "npm-cache")) + data, err := cmd.CombinedOutput() + require.NoError(t, err, "%s", data) + } + run(filepath.Join(root, "kit"), "npm", "pack", "--offline", "--pack-destination", filepath.Join(root, "vendor")) + for _, dir := range []string{filepath.Join(root, "parent"), app} { + run(dir, "npm", "install", "--package-lock-only", "--ignore-scripts", "--offline") + } + output := t.TempDir() + inputs, _, err := stageBuildInputs(t.Context(), app, app, output) + require.NoError(t, err) + for _, dir := range inputs.installs { + rel, _ := filepath.Rel(inputs.common, dir) + run(filepath.Join(output, "context/src", rel), "npm", "ci", "--offline") + } + rel, _ := filepath.Rel(inputs.common, app) + staged := filepath.Join(output, "context/src", rel) + run(staged, "node", "-e", `if (require('parent') !== 42 || require('../node_modules/kit') !== 7) process.exit(1)`) + require.NoDirExists(t, filepath.Join(app, "node_modules")) + require.NoDirExists(t, filepath.Join(output, "context/src/kit")) + info, err := os.Lstat(filepath.Join(output, "context/src/parent/node_modules/leaf")) + require.NoError(t, err) + require.Zero(t, info.Mode()&os.ModeSymlink) + // The parent is physically above the app, but the dependency edge is one-way. + require.Equal(t, 2, len(inputs.installs)) + require.True(t, strings.HasSuffix(inputs.installs[0], "parent")) +} diff --git a/builder.go b/builder.go index bf33ecb..4a52021 100644 --- a/builder.go +++ b/builder.go @@ -406,18 +406,38 @@ func generatedFiles(root string) (map[string]generatedFile, error) { } type DockerTemplating struct { - NodeImage string - Static bool + NodeImage string + Static bool + SourceDir string + InstallDirs []string // BuildArgs are the declared build-arg names, sorted. The Dockerfile declares // each as an ARG and promotes it to an ENV in the build stage so `next build` // reads it; the value is supplied at build time via --build-arg. BuildArgs []string } -// NodeImage matches the tested SaaS Starter frontend build substrate. Keeping -// the digest here makes agent-generated builds reproducible instead of silently -// changing whenever a floating node:24-alpine tag moves. -const NodeImage = "node:24.17.0-alpine3.23@sha256:7c70d1235c0b4c2bc9eeed5393d19f1bbdde6885ba0d58ba62bb385d7b0f3ff1" +// Dockerfile JSON strings preserve spaces and prevent instruction injection. +func (d DockerTemplating) AppWorkdir() string { + encoded, _ := json.Marshal("/src/" + d.SourceDir) + return string(encoded) +} +func (d DockerTemplating) RuntimeWorkdir() string { + encoded, _ := json.Marshal("/app/" + d.SourceDir) + return string(encoded) +} +func (d DockerTemplating) InstallWorkdirs() []string { + commands := []string{} + for _, dir := range d.InstallDirs { + encoded, _ := json.Marshal("/src/" + dir) + commands = append(commands, string(encoded)) + } + return commands +} + +// NodeImage pins the build and SSR runtime substrate to a multi-architecture +// index. Refresh through the maintained substrate procedure; the Go constant +// is not visible to Docker dependency automation. +const NodeImage = "node:24.22.1-alpine3.24@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53" func (s *Builder) BuildCapabilities(context.Context, *builderv0.BuildCapabilitiesRequest) (*builderv0.BuildCapabilitiesResponse, error) { // Recipe execution, including Buildx selection, belongs to the CLI. @@ -444,32 +464,43 @@ func (s *Builder) Build(ctx context.Context, req *builderv0.BuildRequest) (*buil return s.Builder.BuildError(fmt.Errorf("invalid docker image name: %s", image.Name)) } + sourceDir := filepath.Clean(s.Settings.NodeSourceDir()) + if !filepath.IsLocal(sourceDir) { + return s.Builder.BuildError(fmt.Errorf("source-dir %q must stay inside the service build context", s.Settings.NodeSourceDir())) + } docker := DockerTemplating{ NodeImage: NodeImage, Static: s.Settings.IsStatic(), + SourceDir: filepath.ToSlash(sourceDir), BuildArgs: s.Settings.BuildArgKeys(), } return s.buildRecipe(ctx, docker, image, req.GetOutputDirectory()) } -// Clear the output directory before rendering so stale artifacts cannot enter -// the recipe inventory and digest. +// Assemble admitted inputs without removing caller-owned output files. func (s *Builder) buildRecipe(ctx context.Context, docker DockerTemplating, image *resources.DockerImage, output string) (*builderv0.BuildResponse, error) { - err := os.RemoveAll(output) + inputs, app, err := stageBuildInputs(ctx, s.Local("%s", docker.SourceDir), s.Location, output) if err != nil { return s.Builder.BuildError(err) } - - err = s.Templates(ctx, docker, services.WithBuilder(builderFS).WithDestination("%s", output)) - if err != nil { + docker.SourceDir = app + for _, root := range inputs.installs { + rel, _ := filepath.Rel(inputs.common, root) + docker.InstallDirs = append(docker.InstallDirs, filepath.ToSlash(rel)) + } + if _, err = services.PrepareRecipeDestination(builderFS, output); err != nil { + return s.Builder.BuildError(err) + } + if err = s.Templates(ctx, docker, services.WithBuilder(builderFS).WithDestination("%s", output)); err != nil { return s.Builder.BuildError(err) } recipe := &builderv0.DockerBuildRecipe{ Name: filepath.Base(image.Name), Dockerfile: "Dockerfile", - Context: ".", + Context: "context", + ContextRoot: builderv0.RecipeContextRoot_RECIPE_CONTEXT_ROOT_OUTPUT, Dockerignore: "dockerignore", Image: image.FullName(), Platforms: []string{"linux/amd64", "linux/arm64"}, diff --git a/builder_recipe_test.go b/builder_recipe_test.go index d79ced7..36084b4 100644 --- a/builder_recipe_test.go +++ b/builder_recipe_test.go @@ -2,9 +2,12 @@ package main import ( "context" + "encoding/json" + "fmt" "net" "os" "path/filepath" + "strings" "testing" "time" @@ -17,12 +20,94 @@ import ( "google.golang.org/grpc/status" ) +// A recipe projects its declared application inputs into the output directory. Attached projects can put their +// package.json there instead of in code/, and need not declare packages/*. +func TestBuildRecipeUsesConfiguredSourceDirectory(t *testing.T) { + for _, test := range []struct{ name, setting, source, workspace string }{ + {"scaffold", "", "code", "packages/widget"}, + {"code without workspaces", "", "code", ""}, + {"service root", ".", ".", ""}, + {"custom source and workspace", "web ui", "web ui", "extensions/widget"}, + } { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + identity, _ := testIdentity(t, root) + service := filepath.Join(root, "mod", "frontend") + if test.setting != "" { + declaration := filepath.Join(service, "service.codefly.yaml") + data, err := os.ReadFile(declaration) + require.NoError(t, err) + data = append(data, []byte(fmt.Sprintf("\nspec:\n source-dir: %q\n", test.setting))...) + require.NoError(t, os.WriteFile(declaration, data, 0o644)) + } + project := filepath.Join(service, test.source) + require.NoError(t, os.MkdirAll(project, 0o755)) + manifest := `{"name":"fixture","private":true}` + if test.workspace != "" { + manifest = fmt.Sprintf(`{"name":"fixture","private":true,"workspaces":[%q]}`, test.workspace) + member := filepath.Join(project, test.workspace) + require.NoError(t, os.MkdirAll(member, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(member, "package.json"), []byte(`{"name":"widget","version":"1.0.0"}`), 0o644)) + } + require.NoError(t, os.WriteFile(filepath.Join(project, "package.json"), []byte(manifest), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(project, "package-lock.json"), []byte(`{"lockfileVersion":3}`), 0o644)) + builder := NewBuilder(NewService()) + _, err := builder.Load(t.Context(), &builderv0.LoadRequest{Identity: identity, CreationMode: &builderv0.CreationMode{Communicate: false}}) + require.NoError(t, err) + // Render through the same Build RPC used by the CLI, then check every + // context COPY against real files, including projects without packages/. + output := t.TempDir() + response, err := builder.Build(t.Context(), &builderv0.BuildRequest{OutputDirectory: output, + BuildContext: &builderv0.BuildContext{Kind: &builderv0.BuildContext_DockerBuildContext{DockerBuildContext: &builderv0.DockerBuildContext{DockerRepository: "registry.example.com"}}}}) + require.NoError(t, err) + require.Equal(t, builderv0.BuildStatus_SUCCESS, response.GetState().GetState()) + require.Equal(t, "context", response.GetResult().GetDockerBuildPlan().GetRecipes()[0].GetContext()) + require.NoError(t, services.VerifyDockerBuildPlan(output, response.GetResult().GetDockerBuildPlan())) + data, err := os.ReadFile(filepath.Join(output, "Dockerfile")) + require.NoError(t, err) + for _, line := range strings.Split(string(data), "\n") { + if !strings.HasPrefix(line, "COPY ") || strings.Contains(line, "--from=") { + continue + } + operands := strings.Fields(strings.TrimPrefix(line, "COPY ")) + if strings.HasPrefix(strings.TrimPrefix(line, "COPY "), "[") { + require.NoError(t, json.Unmarshal([]byte(strings.TrimPrefix(line, "COPY ")), &operands)) + } + for _, input := range operands[:len(operands)-1] { + matches, err := filepath.Glob(filepath.Join(output, "context", input)) + require.NoError(t, err) + require.NotEmpty(t, matches, "recipe input %q absent from service context (%s)", input, line) + } + } + }) + } +} + +func TestBuildRecipeRefusesSourceOutsideServiceContext(t *testing.T) { + identity, _ := testIdentity(t, t.TempDir()) + writeRecipePackage(t, filepath.Join(identity.WorkspacePath, identity.RelativeToWorkspace, "code")) + builder := NewBuilder(NewService()) + _, err := builder.Load(t.Context(), &builderv0.LoadRequest{Identity: identity, CreationMode: &builderv0.CreationMode{Communicate: false}}) + require.NoError(t, err) + for _, source := range []string{"../sibling", t.TempDir()} { + builder.Settings.SourceDir = source + output := filepath.Join(t.TempDir(), "recipe") + response, err := builder.Build(t.Context(), &builderv0.BuildRequest{OutputDirectory: output, + BuildContext: &builderv0.BuildContext{Kind: &builderv0.BuildContext_DockerBuildContext{DockerBuildContext: &builderv0.DockerBuildContext{DockerRepository: "registry.example.com"}}}}) + require.NoError(t, err) + require.Equal(t, builderv0.BuildStatus_ERROR, response.GetState().GetState()) + require.Contains(t, response.GetState().GetMessage(), "inside the service build context") + require.NoDirExists(t, output) + } +} + // When the CLI sends an output_directory, Build renders the recipe there and // returns a DockerBuildPlan the CLI can verify and buildx, instead of running // docker build in-process. The test never needs a docker daemon. func TestBuildEmitsRecipeWhenOutputDirectorySet(t *testing.T) { ctx := context.Background() identity, _ := testIdentity(t, t.TempDir()) + writeRecipePackage(t, filepath.Join(identity.WorkspacePath, identity.RelativeToWorkspace, "code")) builder := NewBuilder(NewService()) _, err := builder.Load(ctx, &builderv0.LoadRequest{ @@ -32,8 +117,7 @@ func TestBuildEmitsRecipeWhenOutputDirectorySet(t *testing.T) { require.NoError(t, err) output := t.TempDir() - // A leftover artifact in the output directory (e.g. an ignore file a prior - // interrupted CLI build staged) must not leak into the digested recipe tree. + // Unrelated caller-owned files survive and enter the whole TREE inventory. require.NoError(t, os.WriteFile(filepath.Join(output, "Dockerfile.dockerignore"), []byte("stale"), 0o644)) response, err := builder.Build(ctx, &builderv0.BuildRequest{ @@ -51,26 +135,28 @@ func TestBuildEmitsRecipeWhenOutputDirectorySet(t *testing.T) { plan := response.GetResult().GetDockerBuildPlan() require.NotNil(t, plan) - require.Equal(t, services.DockerBuildRecipeContractVersion, plan.GetContractVersion()) + require.Equal(t, services.DockerBuildRecipeContextContractVersion, plan.GetContractVersion()) require.Contains(t, plan.GetDigest(), "sha256:") require.Len(t, plan.GetRecipes(), 1) recipe := plan.GetRecipes()[0] require.Equal(t, "frontend", recipe.GetName()) require.Equal(t, "Dockerfile", recipe.GetDockerfile()) - require.Equal(t, ".", recipe.GetContext()) + require.Equal(t, "context", recipe.GetContext()) + require.Equal(t, builderv0.RecipeContextRoot_RECIPE_CONTEXT_ROOT_OUTPUT, recipe.GetContextRoot()) require.Equal(t, "dockerignore", recipe.GetDockerignore()) require.Equal(t, []string{"linux/amd64", "linux/arm64"}, recipe.GetPlatforms()) require.Contains(t, recipe.GetImage(), "registry.example.com/mod/frontend") - // The recipe tree is exactly what the agent rendered: the stale file is gone - // and never entered the plan inventory. - require.NoFileExists(t, filepath.Join(output, "Dockerfile.dockerignore")) + // TREE verification covers preserved caller files as well as generated files. + require.FileExists(t, filepath.Join(output, "Dockerfile.dockerignore")) var inventory []string for _, f := range plan.GetFiles() { inventory = append(inventory, f.GetPath()) } - require.ElementsMatch(t, []string{"Dockerfile", "dockerignore"}, inventory) + require.Contains(t, inventory, "context/src/package.json") + require.Contains(t, inventory, "context/next-build.cjs") + require.Contains(t, inventory, "Dockerfile.dockerignore") require.FileExists(t, filepath.Join(output, "Dockerfile")) require.FileExists(t, filepath.Join(output, "dockerignore")) @@ -92,6 +178,7 @@ func TestBuildEmitsRecipeWhenOutputDirectorySet(t *testing.T) { func TestBuildRecipeCarriesDeclaredBuildArgs(t *testing.T) { ctx := context.Background() identity, _ := testIdentity(t, t.TempDir()) + writeRecipePackage(t, filepath.Join(identity.WorkspacePath, identity.RelativeToWorkspace, "code")) builder := NewBuilder(NewService()) _, err := builder.Load(ctx, &builderv0.LoadRequest{ @@ -152,6 +239,7 @@ func TestRecipeBuildNegotiationOverGRPC(t *testing.T) { root := t.TempDir() identity, _ := testIdentity(t, root) + writeRecipePackage(t, filepath.Join(root, "mod", "frontend", "code")) _, err = client.Load(ctx, &builderv0.LoadRequest{ Identity: identity, CreationMode: &builderv0.CreationMode{Communicate: false}, @@ -195,3 +283,10 @@ func TestRecipeBuildNegotiationOverGRPC(t *testing.T) { }) } } + +func writeRecipePackage(t *testing.T, dir string) { + t.Helper() + require.NoError(t, os.MkdirAll(dir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"fixture","version":"1.0.0"}`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte(`{"name":"fixture","version":"1.0.0","lockfileVersion":3,"packages":{"":{"name":"fixture","version":"1.0.0"}}}`), 0644)) +} diff --git a/builder_sbom_test.go b/builder_sbom_test.go index 37954a4..ecd094b 100644 --- a/builder_sbom_test.go +++ b/builder_sbom_test.go @@ -22,6 +22,7 @@ func loadedBuilder(t *testing.T) *Builder { CreationMode: &builderv0.CreationMode{Communicate: false}, }) require.NoError(t, err) + writeRecipePackage(t, builder.Local("%s", builder.Settings.NodeSourceDir())) return builder } diff --git a/code.go b/code.go index 0b42a09..88774b9 100644 --- a/code.go +++ b/code.go @@ -111,9 +111,11 @@ func nodeProjectLanguage(sourceFiles []*codev0.SourceFileInfo) string { } type nodePackageManifest struct { - Scripts map[string]string `json:"scripts"` - Dependencies map[string]string `json:"dependencies"` - DevDependencies map[string]string `json:"devDependencies"` + Scripts map[string]string `json:"scripts"` + OptionalDependencies map[string]string `json:"optionalDependencies"` + PeerDependencies map[string]string `json:"peerDependencies"` + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` } type nodeProjectKind string diff --git a/go.mod b/go.mod index eaeeffd..c057ecb 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,10 @@ go 1.27.3 require ( github.com/Masterminds/semver v1.5.0 - github.com/codefly-dev/core v0.19.10 + github.com/codefly-dev/core v0.19.13 + github.com/moby/patternmatcher v0.6.1 github.com/stretchr/testify v1.12.1 - google.golang.org/grpc v1.84.0 + google.golang.org/grpc v1.85.0 google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 ) @@ -51,13 +52,13 @@ require ( github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect - github.com/go-openapi/analysis v1.0.0 // indirect + github.com/go-openapi/analysis v1.0.1 // indirect github.com/go-openapi/errors v0.22.9 // indirect - github.com/go-openapi/jsonpointer v1.0.1 // indirect - github.com/go-openapi/jsonreference v1.0.2 // indirect + github.com/go-openapi/jsonpointer v1.0.2 // indirect + github.com/go-openapi/jsonreference v1.0.3 // indirect github.com/go-openapi/loads v0.25.3 // indirect github.com/go-openapi/spec v1.0.1 // indirect - github.com/go-openapi/strfmt v0.27.3 // indirect + github.com/go-openapi/strfmt v0.27.4 // indirect github.com/go-openapi/swag v0.29.2 // indirect github.com/go-openapi/swag/cmdutils v0.29.2 // indirect github.com/go-openapi/swag/conv v0.29.2 // indirect @@ -87,7 +88,6 @@ require ( github.com/mattn/go-pointer v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.28 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect - github.com/moby/patternmatcher v0.6.1 // indirect github.com/moby/sys/sequential v0.7.0 // indirect github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/morikuni/aec v1.1.0 // indirect @@ -132,12 +132,12 @@ require ( golang.org/x/crypto v0.58.0 // indirect golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect golang.org/x/mod v0.42.0 // indirect - golang.org/x/net v0.61.0 // indirect + golang.org/x/net v0.62.0 // indirect golang.org/x/sys v0.49.0 // indirect golang.org/x/term v0.47.0 // indirect golang.org/x/text v0.43.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20261012220534-4341edf3f941 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20261026165753-2ffab32bce7e // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20261026165753-2ffab32bce7e // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect diff --git a/go.sum b/go.sum index 8a0ab7f..61e4af1 100644 --- a/go.sum +++ b/go.sum @@ -43,8 +43,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA= github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c= -github.com/codefly-dev/core v0.19.10 h1:rTeh1vph0xGtpiCCt2Dl1AH2Rz9hzIYt1YGcbv6bLYE= -github.com/codefly-dev/core v0.19.10/go.mod h1:aTJY3Rw0OSVkbWRThgDzSkqieoF9mW4MiPRXM8Vzxww= +github.com/codefly-dev/core v0.19.13 h1:PdBd4JZhNSr/IRlm2Xic6R3vsZyPRhhCC2NDmquXh3E= +github.com/codefly-dev/core v0.19.13/go.mod h1:Q7PHKw8SFb1wNxlTy2EgG11cI9Zmq55TYY1fdsQUhpE= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -101,20 +101,20 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/go-openapi/analysis v1.0.0 h1:sNvbAGCJqUTqIAodr9IVqJMmuZas3YS9ms1dGK9yiJ4= -github.com/go-openapi/analysis v1.0.0/go.mod h1:NhYjJ57fnE+bcE7UwrJyMkhWA3Dfz7TdiBfTVAnos4Y= +github.com/go-openapi/analysis v1.0.1 h1:JTEyujgl4GyFj8pbMUY2yZx5ak1Ozt/DaqzQjQBnBWM= +github.com/go-openapi/analysis v1.0.1/go.mod h1:f3gYbXmnXSQIZB02DqE4f8JX969PdOEnaY7gDBLeJjI= github.com/go-openapi/errors v0.22.9 h1:HI9+SyVYiRzyeBQGv0CbWbQa+u2S0nny7G7AhY+UMiM= github.com/go-openapi/errors v0.22.9/go.mod h1:R73q66sXP2smzAGgFOI4nqtusRK2xN5pYCjsij+KX58= -github.com/go-openapi/jsonpointer v1.0.1 h1:2KxywRmNwJkT/FMBa3iRNHEaAxSJvjqoufQZy3au1Mg= -github.com/go-openapi/jsonpointer v1.0.1/go.mod h1:wI7ZYsFmbIi9nBXOZqgDaS/bqOchRGZjqxFli7FBYxY= -github.com/go-openapi/jsonreference v1.0.2 h1:oS4et8FOf3p3UQxEo4Xt0esijmBUM+F259Xl72OSZsc= -github.com/go-openapi/jsonreference v1.0.2/go.mod h1:TbUNSOo+fcorZjFaNoiSDSoaNnnZtqJtLGR1PuvE/Cs= +github.com/go-openapi/jsonpointer v1.0.2 h1:NbW7xT2b+l7x+JefiPGDsZWsrLk1ogrz4AHK1TTzMt8= +github.com/go-openapi/jsonpointer v1.0.2/go.mod h1:N0rI4MbNKD7xAFTiHtB+5Nhrg2j/wwYg7m7Fmb4/jk8= +github.com/go-openapi/jsonreference v1.0.3 h1:5rDbq+JsjCv6/nUDDJt83nLZoADI4w03uvHXer+4aYQ= +github.com/go-openapi/jsonreference v1.0.3/go.mod h1:OJSlMXHysgH272D9z2tiy2MGe5A9vQLHaCPb6KbYdE4= github.com/go-openapi/loads v0.25.3 h1:V+jKy/thXWdLJUuYC8sZX2dICyAa8M3DokF70jj34P0= github.com/go-openapi/loads v0.25.3/go.mod h1:LgLyCSOLBL2Qnj0Ps1oo2YH8jZoEKsMeVZCc+ALIxFo= github.com/go-openapi/spec v1.0.1 h1:lj2vdGpNDcVgwRc6qXdw6qt/KQpCtSa9tnUH6vpDPDk= github.com/go-openapi/spec v1.0.1/go.mod h1:M//GWQGtDUAjnP37gE6fInLgaczB+FatoipV3H1fYw8= -github.com/go-openapi/strfmt v0.27.3 h1:cyf4J5Wjpd6bFpAs44MF8PsbWF/mWXhn82Ck/hgQZgw= -github.com/go-openapi/strfmt v0.27.3/go.mod h1:Bl+xadPLa3nhBiaCAxNND1vrQfzyEjF0vBrLx96n/4E= +github.com/go-openapi/strfmt v0.27.4 h1:E1xsvHb8R9vsuYru4TP2PVVTv9AIgSFAUACUZrFQfCI= +github.com/go-openapi/strfmt v0.27.4/go.mod h1:SCOff81PPhU26acBiW96jHaFycEbT2nkQRvojM+V2z0= github.com/go-openapi/swag v0.29.2 h1:9n8frkcsuQRA0INU31VbxNxt1zBaHxrHYGEf5BUjFi4= github.com/go-openapi/swag v0.29.2/go.mod h1:RkCiX1gCVXWgLOavDXE3ALJrQ/VO2ziiIzOUs3z10xo= github.com/go-openapi/swag/cmdutils v0.29.2 h1:cXEzX/nWCODon251f+1HgL75/R4XXML5IZxgLCWMoro= @@ -164,8 +164,8 @@ github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfh github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.32.0 h1:QMILzF8BA7xftE4zVORkRrlv08m12St3csK4RsyRgRs= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.32.0/go.mod h1:+ZQ6iUL8+ATyxGDQNuRKpNwJFdGDUi0e7E1QL9MvI7U= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.33.0 h1:5T5Ns3kMpWr+z3s5ZA0e7ljVyWNVNxK76sHPAxi5D1k= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.33.0/go.mod h1:oWNYw09cH8wl8fFgC5hKlBLxR4XfKy2KQpP8MJjeCcc= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -335,8 +335,8 @@ golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e/go.mod h1:DBoxwWhjaW8DJJw/Ye golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= -golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -360,12 +360,12 @@ golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20261012220534-4341edf3f941 h1:DS/w3N8rsfrwxwgGSj6WhMsPo7aNQGmDx74J81zd0NQ= -google.golang.org/genproto/googleapis/api v0.0.0-20261012220534-4341edf3f941/go.mod h1:AuGAdi9KnXss7xbLxIKhCvk+Ro/SxpET/iS45DNYN+M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 h1:0SXBqli5dnuCKMjI/X3FM226zEaEa0GUcSCJtN1vL2o= -google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= -google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= -google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= +google.golang.org/genproto/googleapis/api v0.0.0-20261026165753-2ffab32bce7e h1:e789sLZjeTdc02ZCQGTSzHOu0fR73+dwPQVKUp67Z88= +google.golang.org/genproto/googleapis/api v0.0.0-20261026165753-2ffab32bce7e/go.mod h1:vHV3wJM9WZyfV7GyB+OQGq5dWVrFwCDxapPK4WlRD88= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261026165753-2ffab32bce7e h1:w3fy/G8OrJHYexn+u5y7hFyc8YRRIr7DCevtIOwAcGY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261026165753-2ffab32bce7e/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/grpc v1.85.0 h1:BPBCfuVRQ1qvBhKDOq9uvQllMvO59rKsYhXpp7Dd8vw= +google.golang.org/grpc v1.85.0/go.mod h1:0rQrOD58yjal/0EaVbGBANindixsCZNQPDV3VKtaKuQ= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/next_output_test.go b/next_output_test.go new file mode 100644 index 0000000..4f3c7c1 --- /dev/null +++ b/next_output_test.go @@ -0,0 +1,168 @@ +package main + +import ( + "encoding/json" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// This opt-in test installs the real framework and runs its actual production +// compiler. Recipe unit tests alone cannot establish the standalone layout. +func TestNextConfiguredStandaloneOutput(t *testing.T) { + if os.Getenv("CODEFLY_TEST_NEXT_BUILD") != "1" { + t.Skip("set CODEFLY_TEST_NEXT_BUILD=1 for real npm/Next build") + } + for _, nested := range []bool{false, true} { + t.Run(map[bool]string{false: "default", true: "custom_nested"}[nested], func(t *testing.T) { + root := physicalPath(t.TempDir()) + source := filepath.Join(root, "source") + app := source + dist := ".next" + relative := "" + if nested { + app = filepath.Join(source, "services", "web") + dist = ".next-build" + relative = "services/web" + } + manifest := map[string]any{"name": "output-fixture", "private": true, "scripts": map[string]string{"build": "next build --webpack"}, "dependencies": map[string]string{"next": "16.4.1", "react": "19.2.4", "react-dom": "19.2.4"}} + writeManifest := func() { + data, err := json.Marshal(manifest) + require.NoError(t, err) + buildInputWrite(t, app, "package.json", string(data)) + } + writeManifest() + buildInputWrite(t, app, "pages/index.js", `export default function Page() {return
actual standalone fixture
}`) + buildInputWrite(t, app, "public/asset.txt", "public asset") + config := `export default {output:'standalone',distDir:process.env.NODE_ENV==='production'?'` + dist + `':'.next',outputFileTracingRoot:` + tracing, _ := json.Marshal(source) + config += string(tracing) + `,experimental:{cpus:1}};` + buildInputWrite(t, app, "next.config.mjs", config) + helper, err := fs.ReadFile(builderFS, "templates/builder/context/next-build.cjs.tmpl") + require.NoError(t, err) + buildInputWrite(t, root, "next-build.cjs", string(helper)) + run := func(args ...string) (string, error) { + cmd := exec.CommandContext(t.Context(), args[0], args[1:]...) + cmd.Dir = app + cmd.Env = append(os.Environ(), "NODE_ENV=production", "NEXT_TELEMETRY_DISABLED=1", "npm_config_cache="+filepath.Join(root, "npm-cache")) + data, err := cmd.CombinedOutput() + return string(data), err + } + // Production config is loaded only by the build-stage helper, after a normal + // application install. No host Next installation supplies this fixture. + out, err := run("npm", "install", "--include=dev", "--no-audit", "--no-fund") + require.NoError(t, err, "%s", out) + runtime := filepath.Join(root, "runtime") + out, err = run("node", filepath.Join(root, "next-build.cjs"), source, runtime, "standalone") + require.NoError(t, err, "%s", out) + require.FileExists(t, filepath.Join(runtime, relative, "server.js")) + require.FileExists(t, filepath.Join(runtime, relative, "public/asset.txt")) + require.DirExists(t, filepath.Join(runtime, relative, dist, "static")) + data, err := os.ReadFile(filepath.Join(app, dist, "required-server-files.json")) + require.NoError(t, err) + var metadata struct { + RelativeAppDir string `json:"relativeAppDir"` + } + require.NoError(t, json.Unmarshal(data, &metadata)) + require.Equal(t, relative, metadata.RelativeAppDir) + t.Logf("real Next output %s standalone/%sserver.js", dist, strings.TrimSuffix(relative, "/")+map[bool]string{false: "", true: "/"}[nested]) + // A pre-existing staged output cannot be mistaken for this build, and is + // preserved on refusal rather than recursively deleting admitted inputs. + out, err = run("node", filepath.Join(root, "next-build.cjs"), source, filepath.Join(root, "stale-runtime"), "standalone") + require.Error(t, err) + require.Contains(t, out, "distDir overlaps staged input") + require.FileExists(t, filepath.Join(app, dist, "required-server-files.json")) + require.NoDirExists(t, filepath.Join(root, "stale-runtime")) + require.NoError(t, os.RemoveAll(filepath.Join(app, dist))) // this test's own generated output + manifest["scripts"] = map[string]string{"build": "node -e \"process.exit(0)\""} + writeManifest() + out, err = run("node", filepath.Join(root, "next-build.cjs"), source, filepath.Join(root, "missing-runtime"), "standalone") + require.Error(t, err) + require.Contains(t, out, "required-server-files.json") + require.NoDirExists(t, filepath.Join(root, "missing-runtime")) + if nested { + buildInputWrite(t, app, "tamper-output.cjs", `const fs=require('fs');const p='.next-build/required-server-files.json';const m=JSON.parse(fs.readFileSync(p));m.relativeAppDir='wrong';fs.writeFileSync(p,JSON.stringify(m));`) + manifest["scripts"] = map[string]string{"build": "next build --webpack && node tamper-output.cjs"} + writeManifest() + out, err = run("node", filepath.Join(root, "next-build.cjs"), source, filepath.Join(root, "mismatch-runtime"), "standalone") + require.Error(t, err) + require.Contains(t, out, "fresh Next output metadata disagrees") + require.NoDirExists(t, filepath.Join(root, "mismatch-runtime")) + require.NoError(t, os.RemoveAll(filepath.Join(app, dist))) + } + buildInputWrite(t, app, "next.config.mjs", `export default {};`) + out, err = run("node", filepath.Join(root, "next-build.cjs"), source, filepath.Join(root, "nonstandalone-runtime"), "standalone") + require.Error(t, err) + require.Contains(t, out, "hosted Next output must be standalone") + // Even a supported config cannot designate the application's source root + // for cleanup. Its source remains intact after the refusal. + buildInputWrite(t, app, "next.config.mjs", `export default {output:'standalone',distDir:'.'};`) + out, err = run("node", filepath.Join(root, "next-build.cjs"), source, filepath.Join(root, "overlap-runtime"), "standalone") + require.Error(t, err) + require.FileExists(t, filepath.Join(app, "pages/index.js")) + if !nested { + buildInputWrite(t, app, "next.config.mjs", `export default {output:'export',experimental:{cpus:1}};`) + manifest["scripts"] = map[string]string{"build": "next build --webpack"} + writeManifest() + out, err = run("node", filepath.Join(root, "next-build.cjs"), source, filepath.Join(root, "export-runtime"), "export") + require.NoError(t, err, "%s", out) + require.FileExists(t, filepath.Join(root, "export-runtime/index.html")) + } + + }) + } +} + +// A successful build script is not evidence that a pre-existing static export +// came from this build. Exercise the real Next config loader with a no-op script. +func TestNextExportRejectsStaleOutput(t *testing.T) { + if os.Getenv("CODEFLY_TEST_NEXT_BUILD") != "1" { + t.Skip("set CODEFLY_TEST_NEXT_BUILD=1 for real npm/Next config") + } + root := physicalPath(t.TempDir()) + app := filepath.Join(root, "source") + buildInputWrite(t, app, "package.json", `{"name":"export-freshness-fixture","private":true,"scripts":{"build":"node -e \"require('fs').writeFileSync('build-invoked','yes')\""},"dependencies":{"next":"16.4.1","react":"19.2.4","react-dom":"19.2.4"}}`) + helper, err := fs.ReadFile(builderFS, "templates/builder/context/next-build.cjs.tmpl") + require.NoError(t, err) + buildInputWrite(t, root, "next-build.cjs", string(helper)) + run := func(args ...string) (string, error) { + cmd := exec.CommandContext(t.Context(), args[0], args[1:]...) + cmd.Dir = app + cmd.Env = append(os.Environ(), "NODE_ENV=production", "NEXT_TELEMETRY_DISABLED=1", "npm_config_cache="+filepath.Join(root, "npm-cache")) + data, err := cmd.CombinedOutput() + return string(data), err + } + out, err := run("npm", "install", "--include=dev", "--no-audit", "--no-fund") + require.NoError(t, err, "%s", out) + for _, test := range []struct{ name, dist, export string }{{"default", ".next", "out"}, {"custom", ".export-build", ".export-build"}} { + t.Run(test.name, func(t *testing.T) { + t.Cleanup(func() { _ = os.Remove(filepath.Join(app, "build-invoked")) }) + buildInputWrite(t, app, "next.config.mjs", `export default {output:'export',distDir:'`+test.dist+`'};`) + sentinel := "stale export must survive refusal" + buildInputWrite(t, app, filepath.Join(test.export, "index.html"), sentinel) + runtime := filepath.Join(root, "runtime-"+test.name) + out, err := run("node", filepath.Join(root, "next-build.cjs"), app, runtime, "export") + require.Error(t, err, "stale export accepted: %s", out) + require.Contains(t, out, "overlaps staged input") + data, err := os.ReadFile(filepath.Join(app, test.export, "index.html")) + require.NoError(t, err) + require.Equal(t, sentinel, string(data)) + require.NoDirExists(t, runtime) + require.NoFileExists(t, filepath.Join(app, "build-invoked")) + // After removing only this fixture's stale export, a successful no-op still + // cannot publish output: the exact current export destination must exist. + require.NoError(t, os.RemoveAll(filepath.Join(app, test.export))) + out, err = run("node", filepath.Join(root, "next-build.cjs"), app, runtime, "export") + require.Error(t, err) + require.Contains(t, out, test.export) + require.NoDirExists(t, runtime) + require.FileExists(t, filepath.Join(app, "build-invoked")) + require.NoError(t, os.Remove(filepath.Join(app, "build-invoked"))) + }) + } +} diff --git a/template_contract_test.go b/template_contract_test.go index 74b9356..456de0e 100644 --- a/template_contract_test.go +++ b/template_contract_test.go @@ -126,31 +126,30 @@ func TestBuilderTemplateInstallsWorkspaceGraphReproducibly(t *testing.T) { source := string(dockerfile) for _, required := range []string{ "FROM {{.NodeImage}} AS base", - "COPY code/packages ./packages", + "COPY src/ /src/", "COPY service.codefly.yaml /service.codefly.yaml", "RUN npm ci", - "RUN rm -rf node_modules", - "RUN mkdir -p public && npm run build", + "RUN node /codefly/next-build.cjs", } { if !strings.Contains(source, required) { t.Fatalf("Dockerfile template missing %q", required) } } - if strings.Index(source, "COPY code/packages ./packages") > strings.Index(source, "RUN npm ci") { - t.Fatal("workspace packages must be copied before npm ci") + copySource := strings.Index(source, "COPY src/ /src/") + install := strings.Index(source, "RUN npm ci") + copyInstalled := strings.Index(source, "COPY --from=deps /src/ /src/") + build := strings.Index(source, "RUN node /codefly/next-build.cjs") + if !(copySource < install && install < copyInstalled && copyInstalled < build) { + t.Fatal("build must use the complete source and clean workspace installs from the dependency stage") } - sourceCopy := strings.LastIndex(source, "COPY code/ .") - cleanInstall := strings.Index(source, "RUN rm -rf node_modules") - dependencyCopy := strings.LastIndex(source, "COPY --from=deps /app/node_modules ./node_modules") - build := strings.Index(source, "RUN mkdir -p public && npm run build") - if sourceCopy < 0 || !(sourceCopy < cleanInstall && cleanInstall < dependencyCopy && dependencyCopy < build) { - t.Fatal("builder must replace host node_modules with the clean dependency layer before build") + ignore, err := fs.ReadFile(builderFS, "templates/builder/dockerignore.tmpl") + if err != nil { + t.Fatal(err) } - // A workspace dependency npm could not hoist lives in - // packages/