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//node_modules, which only the deps stage has. - workspaceCopy := strings.LastIndex(source, "COPY --from=deps /app/packages ./packages") - if workspaceCopy < 0 || !(sourceCopy < workspaceCopy && workspaceCopy < build) { - t.Fatal("builder must restore the workspace packages' nested installs from the deps stage before build") + for _, excluded := range []string{"**/node_modules", "**/.next", "**/.git"} { + if !strings.Contains(string(ignore), excluded) { + t.Fatalf("recipe must exclude host artifacts at every source/workspace depth: %s", excluded) + } } if strings.Contains(source, "node:{{.NodeVersion}}") { t.Fatal("Dockerfile template must not use the floating Node major tag") @@ -168,7 +167,7 @@ func TestBuilderTemplateInstallsWorkspaceGraphReproducibly(t *testing.T) { if err != nil { t.Fatalf("parse Dockerfile template: %v", err) } - if err := parsed.Execute(rendered, DockerTemplating{NodeImage: NodeImage}); err != nil { + if err := parsed.Execute(rendered, DockerTemplating{SourceDir: "code", NodeImage: NodeImage}); err != nil { t.Fatalf("render Dockerfile template: %v", err) } if !strings.HasPrefix(rendered.String(), "FROM "+NodeImage+" AS base") { @@ -192,6 +191,7 @@ func TestBuilderTemplateRendersDeclaredBuildArgsBeforeBuild(t *testing.T) { // the build step so `next build` reads it. rendered := &bytes.Buffer{} if err := parsed.Execute(rendered, DockerTemplating{ + SourceDir: "code", NodeImage: NodeImage, BuildArgs: []string{"FRONTEND_SKIN_RUNTIME", "OTHER_FLAG"}, }); err != nil { @@ -208,13 +208,13 @@ func TestBuilderTemplateRendersDeclaredBuildArgsBeforeBuild(t *testing.T) { t.Fatalf("rendered Dockerfile missing %q:\n%s", required, out) } } - if strings.Index(out, "ARG FRONTEND_SKIN_RUNTIME") > strings.Index(out, "npm run build") { + if strings.Index(out, "ARG FRONTEND_SKIN_RUNTIME") > strings.Index(out, "RUN node /codefly/next-build.cjs") { t.Fatal("build-args must be declared before the build step") } // With no build-args declared, no stray ARG/ENV is emitted. bare := &bytes.Buffer{} - if err := parsed.Execute(bare, DockerTemplating{NodeImage: NodeImage}); err != nil { + if err := parsed.Execute(bare, DockerTemplating{SourceDir: "code", NodeImage: NodeImage}); err != nil { t.Fatalf("render Dockerfile template: %v", err) } if strings.Contains(bare.String(), "\nARG ") { @@ -294,7 +294,7 @@ func TestRenderedDockerfilePinsEveryExternalImageByDigest(t *testing.T) { fromLine := regexp.MustCompile(`(?im)^FROM\s+(?:--platform=\S+\s+)?(\S+)(?:\s+AS\s+(\S+))?[ \t]*$`) for _, static := range []bool{true, false} { rendered := &bytes.Buffer{} - if err := parsed.Execute(rendered, DockerTemplating{NodeImage: NodeImage, Static: static}); err != nil { + if err := parsed.Execute(rendered, DockerTemplating{SourceDir: "code", NodeImage: NodeImage, Static: static}); err != nil { t.Fatalf("static=%v: render Dockerfile: %v", static, err) } out := rendered.String() @@ -328,7 +328,7 @@ func TestRenderedDockerfilePinsEveryExternalImageByDigest(t *testing.T) { if !strings.HasPrefix(runner, "nginx:") { t.Fatalf("static runner is %q, want a pinned nginx image", runner) } - for _, required := range []string{"COPY --from=builder /app/out /usr/share/nginx/html", `CMD ["nginx", "-g", "daemon off;"]`} { + for _, required := range []string{"COPY --from=builder /codefly/runtime/ /usr/share/nginx/html", `CMD ["nginx", "-g", "daemon off;"]`} { if !strings.Contains(out, required) { t.Fatalf("static runner missing %q", required) } @@ -382,7 +382,7 @@ func TestBuilderTemplateRecordsTheResolvedOSPackageSetInBothRunners(t *testing.T } for _, static := range []bool{true, false} { rendered := &bytes.Buffer{} - if err := parsed.Execute(rendered, DockerTemplating{NodeImage: NodeImage, Static: static}); err != nil { + if err := parsed.Execute(rendered, DockerTemplating{SourceDir: "code", NodeImage: NodeImage, Static: static}); err != nil { t.Fatalf("static=%v: render Dockerfile: %v", static, err) } // Evidence that stays in a discarded build stage is not evidence. diff --git a/templates/agent/README.md.tmpl b/templates/agent/README.md.tmpl index 78cdd31..64a2bc8 100644 --- a/templates/agent/README.md.tmpl +++ b/templates/agent/README.md.tmpl @@ -68,4 +68,53 @@ The value must be a positive duration no greater than ten minutes. ## Build -The service builds as a standalone Docker image for production deployment. +The service builds a standalone Docker image for production deployment. +Build and SSR stages use the multi-architecture Node index pinned by `NodeImage` +in `builder.go`; the static runner uses the nginx index pinned in the builder +Dockerfile. Maintainers refresh these through the substrate procedure. +These deployment image pins are separate from the development runtime companion; +updating an agent binary changes future recipes, not previously built images. + The +application comes from `spec.source-dir` (`code` by default, `.` for the service +root, or a custom directory inside the service). It must declare Next +`output: "standalone"`; static services instead declare `output: "export"`. +Custom production `distDir` and `outputFileTracingRoot` are resolved using the +installed Next configuration and verified against fresh build metadata. Nested +standalone servers retain their relative layout, public files and static assets. +There is no hosted `npm start` fallback. Development and production output may +remain separate. + +The agent projects the application plus its declared relative `file:` packages +and archives, including local inputs in npm locks. Package roots retain their +relative topology; their common ancestor is never copied wholesale. A parent +package may contain the application. Genuine dependency cycles, missing inputs, +absolute/unsupported local references and symlinks are refused. Internal npm +workspaces retain their names; a `packages/` directory is not required. Linked +packages with dependencies need their own lock; copied leaves without an install +graph need no invented lock. Each lock root receives ordinary `npm ci`, in +local dependency order, before the application build. npm owns resolution and +lifecycle scripts. Build scripts needing sibling inputs must declare those +packages or archives; source imports do not authorize extra files. + +Each admitted package's `.dockerignore` applies to its subtree; service-root +exclusions still apply inside the service and cannot be negated by a child +policy. Host `node_modules`, `.next`, `.git`, `.cache`, TypeScript build-info and +recognized Next output directories (containing `required-server-files.json`) +are excluded. Such host metadata is used only to exclude artifacts, never to +select production output. Inside the isolated build, Next selects the exact +production directory; any remaining nonempty staged directory at that path is +refused rather than deleted. Static export also guards its exact destination +(`out` by default, or the configured non-default `distDir`) before invoking the +build. A successful no-op cannot publish a host export. Missing or inconsistent +fresh output fails the build. + +The recipe uses Core's existing OUTPUT context and TREE inventory (v4). The CLI +must support that contract and executes the build. Only the marked generated +`context/` child and known recipe files are replaced; unrelated output files +survive and are inventoried. Output cannot contain source roots; output nested +under an admitted package is excluded from projection. TREE verification rejects +added files as well as changed files. No source tree is deleted during staging. + +Source precedes dependency installs because npm lifecycle scripts may need it. +Source edits therefore invalidate the dependency layer as well as the build +layer: this intentionally trades manifest-only cache reuse for correct installs. diff --git a/templates/builder/Dockerfile.tmpl b/templates/builder/Dockerfile.tmpl index 9bcea6c..b5f655a 100644 --- a/templates/builder/Dockerfile.tmpl +++ b/templates/builder/Dockerfile.tmpl @@ -14,42 +14,30 @@ FROM base AS deps RUN apk add --no-cache libc6-compat \ && mkdir -p /codefly \ && apk info -v | sort > /codefly/apk-packages.txt -WORKDIR /app +WORKDIR /src -COPY code/package.json code/package-lock.json* ./ -# npm workspaces are part of the install graph. Copy their complete package -# sources before npm ci so additive application packages behave the same in -# local CI and the production container build. -COPY code/packages ./packages +# Only declared package/archive roots are projected here, with their topology. +# Source precedes installs because npm lifecycle scripts can require it. +COPY src/ /src/ +{{range .InstallWorkdirs}} +WORKDIR {{.}} RUN npm ci +{{end}} -# Build +# Build with the complete set of clean installs, including linked packages. FROM base AS builder -WORKDIR /app -COPY code/ . -# Codefly applications may verify generated service dependencies during their -# build. Keep the service manifest available to that deterministic check without -# copying it into the final runtime image. +COPY --from=deps /src/ /src/ +WORKDIR {{.AppWorkdir}} COPY service.codefly.yaml /service.codefly.yaml -# The Codefly archive builder may receive a service tree that already contains -# host node_modules. Remove it before restoring the clean, lock-derived install; -# otherwise host-absolute .bin symlinks can shadow the container executables. -RUN rm -rf node_modules -COPY --from=deps /app/node_modules ./node_modules -# npm nests a workspace's dependency under packages//node_modules when its -# version conflicts with one hoisted to the root. Those installs exist only in -# the deps stage (host copies are excluded from the context), so restore the -# workspace packages from there too, or the build resolves the hoisted version -# instead. The sources are the same context files the deps stage copied. -COPY --from=deps /app/packages ./packages - +COPY next-build.cjs /codefly/next-build.cjs +ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 {{range .BuildArgs}} ARG {{.}} ENV {{.}}=${{.}} {{- end}} -RUN mkdir -p public && npm run build +RUN node /codefly/next-build.cjs /src /codefly/runtime {{if .Static}}export{{else}}standalone{{end}} {{if .Static}} # Static: serve with nginx. @@ -58,9 +46,9 @@ RUN mkdir -p public && npm run build # readable and so the dependency-update automation bumps the two together. The # digest is the multi-arch index that linux/amd64 and linux/arm64 both resolve # through; a single-platform digest would not satisfy the recipe's platforms. -FROM nginx:1.31.6-alpine@sha256:df221db836e1754089190208cee7eeda94f233197056426eda74a43ab1abeac2 AS runner +FROM nginx:1.31.7-alpine@sha256:892bb2152464f0c5d8972593a5f5afaf8054552580b699d95f8ff4af08c2d33d AS runner -COPY --from=builder /app/out /usr/share/nginx/html +COPY --from=builder /codefly/runtime/ /usr/share/nginx/html COPY --from=deps /codefly/apk-packages.txt /codefly/apk-packages.txt EXPOSE 80 @@ -69,7 +57,7 @@ CMD ["nginx", "-g", "daemon off;"] {{else}} # SSR: standalone Node.js server FROM base AS runner -WORKDIR /app +WORKDIR {{.RuntimeWorkdir}} ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 @@ -83,13 +71,7 @@ RUN rm -rf /usr/local/lib/node_modules/npm \ RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 --ingroup nodejs nextjs -COPY --from=builder /app/public ./public - -RUN mkdir .next -RUN chown nextjs:nodejs .next - -COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ -COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /codefly/runtime/ /app/ COPY --from=deps /codefly/apk-packages.txt /codefly/apk-packages.txt USER nextjs diff --git a/templates/builder/context/next-build.cjs.tmpl b/templates/builder/context/next-build.cjs.tmpl new file mode 100644 index 0000000..eeb3e87 --- /dev/null +++ b/templates/builder/context/next-build.cjs.tmpl @@ -0,0 +1,60 @@ +// Runs only in the isolated build stage, using the application's installed Next. +const fs = require('node:fs'); +const path = require('node:path'); +const { createRequire } = require('node:module'); +const { spawnSync } = require('node:child_process'); + +async function build() { + const app = fs.realpathSync(process.cwd()); + const source = fs.realpathSync(process.argv[2]); + const runtime = path.resolve(process.argv[3]); + const mode = process.argv[4]; + const inside = (root, target) => { + const relative = path.relative(root, target); + return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); + }; + if (!inside(source, app) || inside(source, runtime) || inside(runtime, source)) throw Error('source/runtime overlap'); + const local = createRequire(path.join(app, 'package.json')); + const loadConfig = local('next/dist/server/config').default; + const { PHASE_PRODUCTION_BUILD } = local('next/constants'); + const config = await loadConfig(PHASE_PRODUCTION_BUILD, app); + if (config.output !== mode) throw Error(`hosted Next output must be ${mode}, got ${config.output}`); + const dist = path.resolve(app, config.distDir); + if (path.isAbsolute(config.distDir) || dist === app || !inside(app, dist)) throw Error('distDir must be a child of the application'); + const tracing = fs.realpathSync(config.outputFileTracingRoot || app); + if (!inside(source, tracing) || !inside(tracing, app)) throw Error('tracing root must contain the application within projected inputs'); + // Next exports to out by default, or to the configured non-default distDir. + const exported = path.resolve(app, config.distDir === '.next' ? 'out' : config.distDir); + const outputs = new Set(mode === 'export' ? [dist, exported] : [dist]); + // Refuse every nonempty destination before removing even an empty directory. + // A successful no-op build must never publish a pre-existing host export. + for (const directory of outputs) { + if (fs.existsSync(directory) && fs.readdirSync(directory).length) { + throw Error(`${directory === dist ? 'distDir' : 'export destination'} overlaps staged input: ${directory}`); + } + } + for (const directory of outputs) fs.rmSync(directory, { recursive: true, force: true }); + const result = spawnSync('npm', ['run', 'build'], { cwd: app, stdio: 'inherit', env: process.env }); + if (result.error) throw result.error; + if (result.status !== 0) throw Error(`npm build failed: ${result.status}`); + if (fs.existsSync(runtime)) throw Error('runtime destination already exists'); + if (mode === 'export') { + fs.cpSync(exported, runtime, { recursive: true, dereference: true }); + return; + } + const metadata = JSON.parse(fs.readFileSync(path.join(dist, 'required-server-files.json'), 'utf8')); + const relativeApp = path.relative(tracing, app); + if (metadata.appDir !== app || metadata.relativeAppDir !== relativeApp || + metadata.config.output !== 'standalone' || metadata.config.distDir !== config.distDir || + path.resolve(metadata.config.outputFileTracingRoot || app) !== tracing) throw Error('fresh Next output metadata disagrees with production config'); + const standalone = path.join(dist, 'standalone'); + if (!fs.statSync(path.join(standalone, relativeApp, 'server.js')).isFile()) throw Error('standalone server missing'); + const target = path.join(runtime, path.relative(source, tracing)); + fs.cpSync(standalone, target, { recursive: true, dereference: true }); + const serverRoot = path.join(runtime, path.relative(source, app)); + for (const name of ['public', path.join(config.distDir, 'static')]) { + const from = path.join(app, name); + if (fs.existsSync(from)) fs.cpSync(from, path.join(serverRoot, name), { recursive: true, dereference: true }); + } +} +build().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/templates/builder/dockerignore.tmpl b/templates/builder/dockerignore.tmpl index 6b45b86..f99b8a0 100644 --- a/templates/builder/dockerignore.tmpl +++ b/templates/builder/dockerignore.tmpl @@ -1,7 +1,7 @@ -code/node_modules -code/.next -code/.git -code/tsconfig.tsbuildinfo +**/node_modules +**/.next +**/.git +**/tsconfig.tsbuildinfo .cache builder deployment