From da467726856220abf5af19677ae8e22319db6233 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Fri, 21 Aug 2026 17:13:28 -0400 Subject: [PATCH 1/2] feat(runners): emit build recipes instead of building in-process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When BuildRequest.output_directory is set the shared Go and Rust runners now render the build recipe and return a DockerBuildPlan (WithBuildPlan) instead of running docker build + WithDockerImages. This is the fleet lever: every service agent built on these runners moves to CLI-owned, multi-arch builds just by re-pinning core — no per-repo change. - SingleImageBuildPlan assembles the conventional single-image recipe (builder/Dockerfile, context ".", optional builder/dockerignore) over the service directory, which is the build context the caller verifies. - RecipeBuildPlatforms targets a linux/amd64 + linux/arm64 manifest list so a consumer never needs a local rebuild. - The Go runner only emits a recipe for the standard layout; a custom ContextRoot (workspace-root build) falls through to the in-process build, and the caller uses its legacy push path. Empty output_directory keeps the legacy in-process build, so old CLIs and un-migrated callers are unaffected. Co-Authored-By: Claude Opus 4.8 --- agents/services/docker_recipe.go | 31 +++++++++++ .../docker_recipe_singleimage_test.go | 53 +++++++++++++++++++ runners/golang/agent_builder.go | 14 +++++ runners/rust/agent_builder.go | 11 ++++ 4 files changed, 109 insertions(+) create mode 100644 agents/services/docker_recipe_singleimage_test.go diff --git a/agents/services/docker_recipe.go b/agents/services/docker_recipe.go index a2e1d659..9dd99e95 100644 --- a/agents/services/docker_recipe.go +++ b/agents/services/docker_recipe.go @@ -68,6 +68,37 @@ func BuildDockerBuildPlan(destination string, recipes []*builderv0.DockerBuildRe }, nil } +// RecipeBuildPlatforms is the deployment platform set an emitted recipe targets: +// a linux/amd64 + linux/arm64 manifest list, so a consumer never needs a local +// rebuild regardless of node architecture. The legacy in-process build honors +// the single-platform CODEFLY_BUILD_PLATFORM override; the recipe path supersedes +// it with a multi-arch manifest list. +func RecipeBuildPlatforms() []string { + return []string{"linux/amd64", "linux/arm64"} +} + +// SingleImageBuildPlan assembles the plan for a service that emits one image +// from builder/Dockerfile with the service directory (outputDirectory) as its +// build context — the conventional layout every shared-runner agent renders. A +// runner calls it when the caller requested recipe emission (a non-empty +// BuildRequest.output_directory) instead of building the image in-process, so +// the build recipe becomes a durable artifact the caller (the CLI) builds. +func SingleImageBuildPlan(outputDirectory, image string, platforms []string) (*builderv0.DockerBuildPlan, error) { + dockerignore := "" + if info, err := os.Stat(filepath.Join(outputDirectory, "builder", "dockerignore")); err == nil && info.Mode().IsRegular() { + dockerignore = "builder/dockerignore" + } + recipe := &builderv0.DockerBuildRecipe{ + Name: "app", + Dockerfile: "builder/Dockerfile", + Context: ".", + Dockerignore: dockerignore, + Image: image, + Platforms: platforms, + } + return BuildDockerBuildPlan(outputDirectory, []*builderv0.DockerBuildRecipe{recipe}) +} + // VerifyDockerBuildPlan re-inventories the recipe tree at destination and checks // it against plan. The caller (the CLI) runs this before docker buildx so it // never builds from a tree that drifted from the inventory the agent validated, diff --git a/agents/services/docker_recipe_singleimage_test.go b/agents/services/docker_recipe_singleimage_test.go new file mode 100644 index 00000000..50a5b973 --- /dev/null +++ b/agents/services/docker_recipe_singleimage_test.go @@ -0,0 +1,53 @@ +package services + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func writeRecipeTree(t *testing.T, withIgnore bool) string { + t.Helper() + dir := t.TempDir() + builder := filepath.Join(dir, "builder") + require.NoError(t, os.MkdirAll(builder, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(builder, "Dockerfile"), []byte("FROM alpine\nCOPY . .\n"), 0o644)) + if withIgnore { + require.NoError(t, os.WriteFile(filepath.Join(builder, "dockerignore"), []byte("code/node_modules\n"), 0o644)) + } + return dir +} + +func TestSingleImageBuildPlanConventionalLayout(t *testing.T) { + dir := writeRecipeTree(t, true) + + plan, err := SingleImageBuildPlan(dir, "repo/app:v1", RecipeBuildPlatforms()) + require.NoError(t, err) + require.Len(t, plan.GetRecipes(), 1) + + recipe := plan.GetRecipes()[0] + require.Equal(t, "app", recipe.GetName()) + require.Equal(t, "builder/Dockerfile", recipe.GetDockerfile()) + require.Equal(t, ".", recipe.GetContext()) + require.Equal(t, "builder/dockerignore", recipe.GetDockerignore()) + require.Equal(t, "repo/app:v1", recipe.GetImage()) + require.Equal(t, []string{"linux/amd64", "linux/arm64"}, recipe.GetPlatforms()) + + // The plan the agent emits must verify against the tree the caller sees. + require.NoError(t, VerifyDockerBuildPlan(dir, plan)) +} + +func TestSingleImageBuildPlanOmitsAbsentDockerignore(t *testing.T) { + dir := writeRecipeTree(t, false) + + plan, err := SingleImageBuildPlan(dir, "repo/app:v1", RecipeBuildPlatforms()) + require.NoError(t, err) + require.Equal(t, "", plan.GetRecipes()[0].GetDockerignore()) + require.NoError(t, VerifyDockerBuildPlan(dir, plan)) +} + +func TestRecipeBuildPlatformsCoversDeploymentArch(t *testing.T) { + require.Contains(t, RecipeBuildPlatforms(), "linux/amd64") +} diff --git a/runners/golang/agent_builder.go b/runners/golang/agent_builder.go index 025b2b55..4a35799b 100644 --- a/runners/golang/agent_builder.go +++ b/runners/golang/agent_builder.go @@ -77,6 +77,20 @@ func BuildGoDocker(ctx context.Context, builder *services.BuilderWrapper, return builder.BuildError(err) } + // When the caller owns the build (output_directory set), emit the recipe and + // let the caller run docker buildx instead of building the image in-process. + // A custom ContextRoot builds from a directory other than the service dir, so + // the "context is output_directory" recipe model does not hold — fall through + // to the in-process build, and the caller uses its legacy push path. + if req.GetOutputDirectory() != "" && docker.ContextRoot == "" { + plan, planErr := services.SingleImageBuildPlan(req.GetOutputDirectory(), image.FullName(), services.RecipeBuildPlatforms()) + if planErr != nil { + return builder.BuildError(planErr) + } + builder.WithBuildPlan(plan) + return builder.BuildResponse() + } + configuration, err := goDockerBuilderConfiguration(location, image, w, docker) if err != nil { return builder.BuildError(err) diff --git a/runners/rust/agent_builder.go b/runners/rust/agent_builder.go index 59b89631..72818223 100644 --- a/runners/rust/agent_builder.go +++ b/runners/rust/agent_builder.go @@ -71,6 +71,17 @@ func BuildRustDocker(ctx context.Context, builder *services.BuilderWrapper, return builder.BuildError(err) } + // When the caller owns the build (output_directory set), emit the recipe and + // let the caller run docker buildx instead of building the image in-process. + if req.GetOutputDirectory() != "" { + plan, planErr := services.SingleImageBuildPlan(req.GetOutputDirectory(), image.FullName(), services.RecipeBuildPlatforms()) + if planErr != nil { + return builder.BuildError(planErr) + } + builder.WithBuildPlan(plan) + return builder.BuildResponse() + } + b, err := dockerhelpers.NewBuilder(dockerhelpers.BuilderConfiguration{ Root: location, Dockerfile: "builder/Dockerfile", From 7b9dfede44235e803e08552a0a3e1c447ee35aee Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Fri, 21 Aug 2026 17:55:19 -0400 Subject: [PATCH 2/2] refactor(builder): hoist recipe emission into the shared builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recipe-vs-in-process decision was duplicated in the Go and Rust runners, framing it as a per-language concern. It isn't: the conventional single-image layout (builder/Dockerfile, context = the service directory, optional builder/dockerignore) is language-agnostic, and the build result is exposed to the RPC. So move it into the shared builder: - BuildPlanRequested(req) is the single negotiation point every language runner checks — a non-empty output_directory means the caller owns the build. - BuilderWrapper.SingleImageBuildResponse renders the recipe, records the DockerBuildPlan, and returns the response in one call. Go and Rust now go through these, and Python, Node, and every other agent built on the shared builder adopt CLI-owned multi-arch builds with a single call plus a core re-pin — the actual fleet lever. Co-Authored-By: Claude Opus 4.8 --- agents/services/base_builder.go | 17 ++++++++++++ agents/services/docker_recipe.go | 10 +++++++ .../docker_recipe_singleimage_test.go | 27 +++++++++++++++++++ runners/golang/agent_builder.go | 9 ++----- runners/rust/agent_builder.go | 9 ++----- 5 files changed, 58 insertions(+), 14 deletions(-) diff --git a/agents/services/base_builder.go b/agents/services/base_builder.go index dacedaac..bc6406b7 100644 --- a/agents/services/base_builder.go +++ b/agents/services/base_builder.go @@ -258,6 +258,23 @@ func (s *BuilderWrapper) WithBuildPlan(plan *builderv0.DockerBuildPlan) { } } +// SingleImageBuildResponse renders the conventional single-image recipe over the +// caller's output_directory, records it as the build result, and returns the build +// response. A language runner calls this — instead of building the image in-process +// — when BuildPlanRequested(req) is true. The conventional layout it emits +// (builder/Dockerfile, context = the service directory, an optional +// builder/dockerignore) is language-agnostic, so Go, Rust, Python, Node, and every +// other agent built on the shared builder move to CLI-owned, multi-arch builds +// through this one path — just by re-pinning core. +func (s *BuilderWrapper) SingleImageBuildResponse(req *builderv0.BuildRequest, image string) (*builderv0.BuildResponse, error) { + plan, err := SingleImageBuildPlan(req.GetOutputDirectory(), image, RecipeBuildPlatforms()) + if err != nil { + return s.BuildError(err) + } + s.WithBuildPlan(plan) + return s.BuildResponse() +} + func (s *BuilderWrapper) BuildResponse() (*builderv0.BuildResponse, error) { if !s.loaded { return s.BuildError(fmt.Errorf("not loaded")) diff --git a/agents/services/docker_recipe.go b/agents/services/docker_recipe.go index 9dd99e95..e1aa36d2 100644 --- a/agents/services/docker_recipe.go +++ b/agents/services/docker_recipe.go @@ -77,6 +77,16 @@ func RecipeBuildPlatforms() []string { return []string{"linux/amd64", "linux/arm64"} } +// BuildPlanRequested reports whether the caller (the CLI) owns the build for this +// request — a non-empty BuildRequest.output_directory means the runner should emit +// a recipe into that directory instead of building the image in-process. This is +// the single negotiation point every language runner checks, so the recipe path is +// language-agnostic: Go, Rust, Python, Node — any agent built on the shared builder +// switches to CLI-owned builds by consulting this and nothing else. +func BuildPlanRequested(req *builderv0.BuildRequest) bool { + return req.GetOutputDirectory() != "" +} + // SingleImageBuildPlan assembles the plan for a service that emits one image // from builder/Dockerfile with the service directory (outputDirectory) as its // build context — the conventional layout every shared-runner agent renders. A diff --git a/agents/services/docker_recipe_singleimage_test.go b/agents/services/docker_recipe_singleimage_test.go index 50a5b973..75c1884d 100644 --- a/agents/services/docker_recipe_singleimage_test.go +++ b/agents/services/docker_recipe_singleimage_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0" "github.com/stretchr/testify/require" ) @@ -51,3 +52,29 @@ func TestSingleImageBuildPlanOmitsAbsentDockerignore(t *testing.T) { func TestRecipeBuildPlatformsCoversDeploymentArch(t *testing.T) { require.Contains(t, RecipeBuildPlatforms(), "linux/amd64") } + +func TestBuildPlanRequested(t *testing.T) { + require.False(t, BuildPlanRequested(&builderv0.BuildRequest{})) + require.False(t, BuildPlanRequested(&builderv0.BuildRequest{OutputDirectory: ""})) + require.True(t, BuildPlanRequested(&builderv0.BuildRequest{OutputDirectory: "/abs/out"})) +} + +// The shared wrapper path is what makes the recipe emission language-agnostic: any +// language runner returns SingleImageBuildResponse and gets the same DockerBuildPlan +// result, verifiable against the caller's tree. +func TestSingleImageBuildResponseEmitsPlan(t *testing.T) { + dir := writeRecipeTree(t, true) + base := &Base{loaded: true} + wrapper := &BuilderWrapper{Base: base} + base.Builder = wrapper // WithBuildPlan records onto s.Builder, as production wires it. + + resp, err := wrapper.SingleImageBuildResponse(&builderv0.BuildRequest{OutputDirectory: dir}, "repo/app:v1") + require.NoError(t, err) + require.Equal(t, builderv0.BuildStatus_SUCCESS, resp.GetState().GetState()) + + plan := resp.GetResult().GetDockerBuildPlan() + require.NotNil(t, plan) + require.Len(t, plan.GetRecipes(), 1) + require.Equal(t, "repo/app:v1", plan.GetRecipes()[0].GetImage()) + require.NoError(t, VerifyDockerBuildPlan(dir, plan)) +} diff --git a/runners/golang/agent_builder.go b/runners/golang/agent_builder.go index 4a35799b..02cea923 100644 --- a/runners/golang/agent_builder.go +++ b/runners/golang/agent_builder.go @@ -82,13 +82,8 @@ func BuildGoDocker(ctx context.Context, builder *services.BuilderWrapper, // A custom ContextRoot builds from a directory other than the service dir, so // the "context is output_directory" recipe model does not hold — fall through // to the in-process build, and the caller uses its legacy push path. - if req.GetOutputDirectory() != "" && docker.ContextRoot == "" { - plan, planErr := services.SingleImageBuildPlan(req.GetOutputDirectory(), image.FullName(), services.RecipeBuildPlatforms()) - if planErr != nil { - return builder.BuildError(planErr) - } - builder.WithBuildPlan(plan) - return builder.BuildResponse() + if services.BuildPlanRequested(req) && docker.ContextRoot == "" { + return builder.SingleImageBuildResponse(req, image.FullName()) } configuration, err := goDockerBuilderConfiguration(location, image, w, docker) diff --git a/runners/rust/agent_builder.go b/runners/rust/agent_builder.go index 72818223..98bff66c 100644 --- a/runners/rust/agent_builder.go +++ b/runners/rust/agent_builder.go @@ -73,13 +73,8 @@ func BuildRustDocker(ctx context.Context, builder *services.BuilderWrapper, // When the caller owns the build (output_directory set), emit the recipe and // let the caller run docker buildx instead of building the image in-process. - if req.GetOutputDirectory() != "" { - plan, planErr := services.SingleImageBuildPlan(req.GetOutputDirectory(), image.FullName(), services.RecipeBuildPlatforms()) - if planErr != nil { - return builder.BuildError(planErr) - } - builder.WithBuildPlan(plan) - return builder.BuildResponse() + if services.BuildPlanRequested(req) { + return builder.SingleImageBuildResponse(req, image.FullName()) } b, err := dockerhelpers.NewBuilder(dockerhelpers.BuilderConfiguration{