From b712efce390ee9cdb42418da5deecafaf2089fb1 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:51:38 -0400 Subject: [PATCH 1/6] fix(builder): honor the configured Next.js source root --- builder.go | 12 +++++ builder_recipe_test.go | 83 +++++++++++++++++++++++++++++ template_contract_test.go | 38 ++++++------- templates/agent/README.md.tmpl | 9 ++++ templates/builder/Dockerfile.tmpl | 24 +++------ templates/builder/dockerignore.tmpl | 8 +-- 6 files changed, 134 insertions(+), 40 deletions(-) diff --git a/builder.go b/builder.go index bf33ecb..8b685d3 100644 --- a/builder.go +++ b/builder.go @@ -408,12 +408,19 @@ func generatedFiles(root string) (map[string]generatedFile, error) { type DockerTemplating struct { NodeImage string Static bool + SourceDir 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 } +// JSON COPY syntax preserves spaces and quotes in the declared source path. +func (d DockerTemplating) SourceCopy() string { + encoded, _ := json.Marshal([]string{d.SourceDir, "./"}) + return string(encoded) +} + // 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. @@ -444,9 +451,14 @@ 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(), } diff --git a/builder_recipe_test.go b/builder_recipe_test.go index d79ced7..57ace76 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,6 +20,86 @@ import ( "google.golang.org/grpc/status" ) +// A recipe's context is the service 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, ".", 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(service, 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()) + 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. diff --git a/template_contract_test.go b/template_contract_test.go index 74b9356..df6661c 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 {{.SourceCopy}}", "COPY service.codefly.yaml /service.codefly.yaml", "RUN npm ci", - "RUN rm -rf node_modules", "RUN mkdir -p public && npm run build", } { 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") - } - 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") + copySource := strings.Index(source, "COPY {{.SourceCopy}}") + install := strings.Index(source, "RUN npm ci") + copyInstalled := strings.Index(source, "COPY --from=deps /app/ ./") 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") + if !(copySource < install && install < copyInstalled && copyInstalled < build) { + t.Fatal("build must use the complete source and clean workspace installs from the dependency stage") + } + 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 { @@ -214,7 +214,7 @@ func TestBuilderTemplateRendersDeclaredBuildArgsBeforeBuild(t *testing.T) { // 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() @@ -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..aa2ca6f 100644 --- a/templates/agent/README.md.tmpl +++ b/templates/agent/README.md.tmpl @@ -69,3 +69,12 @@ 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 build context is the service directory. The recipe uses `spec.source-dir` +(`code` by default, or `.` for an application at the service root) and installs +the complete application tree with `npm ci`. Workspaces keep their declared +directory names; a `packages/` directory is not required. Host `node_modules` +and `.next` trees are excluded at every depth. The builder stage receives the +same source and clean install, including nested workspace dependencies. +Source edits therefore invalidate the dependency layer as well as the build +layer. A source path outside the service context is refused; the recipe does +not copy sibling projects from outside that context. diff --git a/templates/builder/Dockerfile.tmpl b/templates/builder/Dockerfile.tmpl index 9bcea6c..d0b7a66 100644 --- a/templates/builder/Dockerfile.tmpl +++ b/templates/builder/Dockerfile.tmpl @@ -16,32 +16,22 @@ RUN apk add --no-cache libc6-compat \ && apk info -v | sort > /codefly/apk-packages.txt WORKDIR /app -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 +# The configured application root owns its complete install graph, including +# any npm workspaces. A project need not have the scaffold's packages/ directory. +# The recipe ignore policy excludes host installs at every depth. +COPY {{.SourceCopy}} RUN npm ci # Build FROM base AS builder WORKDIR /app -COPY code/ . +# Keep the source and all lock-derived installs together, including dependencies +# nested under custom workspace directories that npm could not hoist. +COPY --from=deps /app/ ./ # 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 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 ENV NEXT_TELEMETRY_DISABLED=1 {{range .BuildArgs}} 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 From 30d2862e17179a4033176b24bc925e794be669f8 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:22:29 -0400 Subject: [PATCH 2/6] fix(builder): project declared npm inputs and package configured Next output --- .claude/skills/verify-changes/SKILL.md | 17 + build_context.go | 315 ++++++++++++++++++ build_context_test.go | 187 +++++++++++ builder.go | 45 ++- builder_recipe_test.go | 34 +- builder_sbom_test.go | 1 + code.go | 8 +- go.mod | 2 +- next_output_test.go | 119 +++++++ template_contract_test.go | 14 +- templates/agent/README.md.tmpl | 49 ++- templates/builder/Dockerfile.tmpl | 40 +-- templates/builder/context/next-build.cjs.tmpl | 55 +++ 13 files changed, 818 insertions(+), 68 deletions(-) create mode 100644 build_context.go create mode 100644 build_context_test.go create mode 100644 next_output_test.go create mode 100644 templates/builder/context/next-build.cjs.tmpl diff --git a/.claude/skills/verify-changes/SKILL.md b/.claude/skills/verify-changes/SKILL.md index 0952eb2..ffb57b3 100644 --- a/.claude/skills/verify-changes/SKILL.md +++ b/.claude/skills/verify-changes/SKILL.md @@ -23,6 +23,23 @@ 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 '^TestNextConfiguredStandaloneOutput$' -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. 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 8b685d3..244d562 100644 --- a/builder.go +++ b/builder.go @@ -406,20 +406,33 @@ func generatedFiles(root string) (map[string]generatedFile, error) { } type DockerTemplating struct { - NodeImage string - Static bool - SourceDir string + 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 } -// JSON COPY syntax preserves spaces and quotes in the declared source path. -func (d DockerTemplating) SourceCopy() string { - encoded, _ := json.Marshal([]string{d.SourceDir, "./"}) +// 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 matches the tested SaaS Starter frontend build substrate. Keeping // the digest here makes agent-generated builds reproducible instead of silently @@ -465,23 +478,29 @@ func (s *Builder) Build(ctx context.Context, req *builderv0.BuildRequest) (*buil 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 57ace76..36084b4 100644 --- a/builder_recipe_test.go +++ b/builder_recipe_test.go @@ -20,7 +20,7 @@ import ( "google.golang.org/grpc/status" ) -// A recipe's context is the service directory. Attached projects can put their +// 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 }{ @@ -61,7 +61,7 @@ func TestBuildRecipeUsesConfiguredSourceDirectory(t *testing.T) { 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, ".", response.GetResult().GetDockerBuildPlan().GetRecipes()[0].GetContext()) + 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) @@ -74,7 +74,7 @@ func TestBuildRecipeUsesConfiguredSourceDirectory(t *testing.T) { require.NoError(t, json.Unmarshal([]byte(strings.TrimPrefix(line, "COPY ")), &operands)) } for _, input := range operands[:len(operands)-1] { - matches, err := filepath.Glob(filepath.Join(service, input)) + 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) } @@ -85,6 +85,7 @@ func TestBuildRecipeUsesConfiguredSourceDirectory(t *testing.T) { 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) @@ -106,6 +107,7 @@ func TestBuildRecipeRefusesSourceOutsideServiceContext(t *testing.T) { 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{ @@ -115,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{ @@ -134,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")) @@ -175,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{ @@ -235,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}, @@ -278,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..3fe0f86 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.27.3 require ( github.com/Masterminds/semver v1.5.0 github.com/codefly-dev/core v0.19.10 + github.com/moby/patternmatcher v0.6.1 github.com/stretchr/testify v1.12.1 google.golang.org/grpc v1.84.0 google.golang.org/protobuf v1.36.12 @@ -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 diff --git a/next_output_test.go b/next_output_test.go new file mode 100644 index 0000000..6ddcdf2 --- /dev/null +++ b/next_output_test.go @@ -0,0 +1,119 @@ +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")) + } + + }) + } +} diff --git a/template_contract_test.go b/template_contract_test.go index df6661c..456de0e 100644 --- a/template_contract_test.go +++ b/template_contract_test.go @@ -126,19 +126,19 @@ func TestBuilderTemplateInstallsWorkspaceGraphReproducibly(t *testing.T) { source := string(dockerfile) for _, required := range []string{ "FROM {{.NodeImage}} AS base", - "COPY {{.SourceCopy}}", + "COPY src/ /src/", "COPY service.codefly.yaml /service.codefly.yaml", "RUN npm ci", - "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) } } - copySource := strings.Index(source, "COPY {{.SourceCopy}}") + copySource := strings.Index(source, "COPY src/ /src/") install := strings.Index(source, "RUN npm ci") - copyInstalled := strings.Index(source, "COPY --from=deps /app/ ./") - build := strings.Index(source, "RUN mkdir -p public && npm run build") + 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") } @@ -208,7 +208,7 @@ 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") } @@ -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) } diff --git a/templates/agent/README.md.tmpl b/templates/agent/README.md.tmpl index aa2ca6f..620f891 100644 --- a/templates/agent/README.md.tmpl +++ b/templates/agent/README.md.tmpl @@ -68,13 +68,44 @@ 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 build context is the service directory. The recipe uses `spec.source-dir` -(`code` by default, or `.` for an application at the service root) and installs -the complete application tree with `npm ci`. Workspaces keep their declared -directory names; a `packages/` directory is not required. Host `node_modules` -and `.next` trees are excluded at every depth. The builder stage receives the -same source and clean install, including nested workspace dependencies. +The service builds a standalone Docker image for production deployment. 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. 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. A source path outside the service context is refused; the recipe does -not copy sibling projects from outside that context. +layer: this intentionally trades manifest-only cache reuse for correct installs. diff --git a/templates/builder/Dockerfile.tmpl b/templates/builder/Dockerfile.tmpl index d0b7a66..0efeb14 100644 --- a/templates/builder/Dockerfile.tmpl +++ b/templates/builder/Dockerfile.tmpl @@ -14,32 +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 -# The configured application root owns its complete install graph, including -# any npm workspaces. A project need not have the scaffold's packages/ directory. -# The recipe ignore policy excludes host installs at every depth. -COPY {{.SourceCopy}} +# 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 -# Keep the source and all lock-derived installs together, including dependencies -# nested under custom workspace directories that npm could not hoist. -COPY --from=deps /app/ ./ -# 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 - +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. @@ -50,7 +48,7 @@ RUN mkdir -p public && npm run build # through; a single-platform digest would not satisfy the recipe's platforms. FROM nginx:1.31.6-alpine@sha256:df221db836e1754089190208cee7eeda94f233197056426eda74a43ab1abeac2 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 @@ -59,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 @@ -73,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..35be8b6 --- /dev/null +++ b/templates/builder/context/next-build.cjs.tmpl @@ -0,0 +1,55 @@ +// 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'); + // Staging excludes host Next outputs. Refuse to erase any remaining admitted + // source or install-script output, even if config names it as distDir. + if (fs.existsSync(dist) && fs.readdirSync(dist).length) throw Error(`distDir overlaps staged input: ${dist}`); + fs.rmSync(dist, { 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') { + // Next uses distDir as the export destination when it is non-default. + const exported = path.join(app, config.distDir === '.next' ? 'out' : config.distDir); + 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; }); From 22956248b71ca1e8e39781cbb739fbf80189ed40 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:33:21 -0400 Subject: [PATCH 3/6] fix(builder): refuse stale static exports before building --- .claude/skills/verify-changes/SKILL.md | 5 +- next_output_test.go | 49 +++++++++++++++++++ templates/agent/README.md.tmpl | 5 +- templates/builder/context/next-build.cjs.tmpl | 17 ++++--- 4 files changed, 67 insertions(+), 9 deletions(-) diff --git a/.claude/skills/verify-changes/SKILL.md b/.claude/skills/verify-changes/SKILL.md index ffb57b3..1c6d601 100644 --- a/.claude/skills/verify-changes/SKILL.md +++ b/.claude/skills/verify-changes/SKILL.md @@ -29,11 +29,12 @@ 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 '^TestNextConfiguredStandaloneOutput$' -count=1 -timeout=10m -v . +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. It +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 diff --git a/next_output_test.go b/next_output_test.go index 6ddcdf2..4f3c7c1 100644 --- a/next_output_test.go +++ b/next_output_test.go @@ -117,3 +117,52 @@ func TestNextConfiguredStandaloneOutput(t *testing.T) { }) } } + +// 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/templates/agent/README.md.tmpl b/templates/agent/README.md.tmpl index 620f891..43a1858 100644 --- a/templates/agent/README.md.tmpl +++ b/templates/agent/README.md.tmpl @@ -97,7 +97,10 @@ 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. Missing or inconsistent fresh output fails the build. +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 diff --git a/templates/builder/context/next-build.cjs.tmpl b/templates/builder/context/next-build.cjs.tmpl index 35be8b6..eeb3e87 100644 --- a/templates/builder/context/next-build.cjs.tmpl +++ b/templates/builder/context/next-build.cjs.tmpl @@ -23,17 +23,22 @@ async function build() { 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'); - // Staging excludes host Next outputs. Refuse to erase any remaining admitted - // source or install-script output, even if config names it as distDir. - if (fs.existsSync(dist) && fs.readdirSync(dist).length) throw Error(`distDir overlaps staged input: ${dist}`); - fs.rmSync(dist, { recursive: true, force: true }); + // 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') { - // Next uses distDir as the export destination when it is non-default. - const exported = path.join(app, config.distDir === '.next' ? 'out' : config.distDir); fs.cpSync(exported, runtime, { recursive: true, dereference: true }); return; } From 1e2eaee45d3977196439d6191dfaa9a23b8073d0 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:50:31 -0400 Subject: [PATCH 4/6] fix(builder): refresh pinned Node substrate and reuse nginx update Resolve Node 24.22.1 Alpine 3.24 through the documented multiarch registry procedure. Reuse the exact nginx 1.31.7 pin from existing PR181 commit 21761d4 without changing packaging or runtime-companion semantics. --- builder.go | 8 ++++---- templates/agent/README.md.tmpl | 9 ++++++++- templates/builder/Dockerfile.tmpl | 2 +- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/builder.go b/builder.go index 244d562..4a52021 100644 --- a/builder.go +++ b/builder.go @@ -434,10 +434,10 @@ func (d DockerTemplating) InstallWorkdirs() []string { return commands } -// 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" +// 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. diff --git a/templates/agent/README.md.tmpl b/templates/agent/README.md.tmpl index 43a1858..2ab16c8 100644 --- a/templates/agent/README.md.tmpl +++ b/templates/agent/README.md.tmpl @@ -68,7 +68,14 @@ The value must be a positive duration no greater than ten minutes. ## Build -The service builds a standalone Docker image for production deployment. The +The service builds a standalone Docker image for production deployment. +Build and SSR stages pin Node `24.22.1-alpine3.24` to multi-architecture index +`sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53`. +The static runner pins nginx `1.31.7-alpine` to index +`sha256:892bb2152464f0c5d8972593a5f5afaf8054552580b699d95f8ff4af08c2d33d`. +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"`. diff --git a/templates/builder/Dockerfile.tmpl b/templates/builder/Dockerfile.tmpl index 0efeb14..b5f655a 100644 --- a/templates/builder/Dockerfile.tmpl +++ b/templates/builder/Dockerfile.tmpl @@ -46,7 +46,7 @@ RUN node /codefly/next-build.cjs /src /codefly/runtime {{if .Static}}export{{els # 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 /codefly/runtime/ /usr/share/nginx/html COPY --from=deps /codefly/apk-packages.txt /codefly/apk-packages.txt From 88f783e85b22511ddfe1e547f117e7dd4882a3f9 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:52:01 -0400 Subject: [PATCH 5/6] docs(builder): reference authoritative image pins in the served guide --- templates/agent/README.md.tmpl | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/templates/agent/README.md.tmpl b/templates/agent/README.md.tmpl index 2ab16c8..64a2bc8 100644 --- a/templates/agent/README.md.tmpl +++ b/templates/agent/README.md.tmpl @@ -69,10 +69,9 @@ The value must be a positive duration no greater than ten minutes. ## Build The service builds a standalone Docker image for production deployment. -Build and SSR stages pin Node `24.22.1-alpine3.24` to multi-architecture index -`sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53`. -The static runner pins nginx `1.31.7-alpine` to index -`sha256:892bb2152464f0c5d8972593a5f5afaf8054552580b699d95f8ff4af08c2d33d`. +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 From 37819f787303a79798ecad1bb845650a76cee698 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:07:01 -0400 Subject: [PATCH 6/6] build(deps): pin published Core v0.19.13 for final delivery --- go.mod | 18 +++++++++--------- go.sum | 40 ++++++++++++++++++++-------------------- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/go.mod b/go.mod index 3fe0f86..c057ecb 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +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 ) @@ -52,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 @@ -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=