Skip to content

Commit e68ed92

Browse files
feat: emit a CLI-owned build recipe from Build (#9) (#10)
When the CLI provides an output_directory, Build now renders the Dockerfile there and returns a DockerBuildPlan instead of building the image in-process, so the image becomes a durable, reproducible recipe the CLI builds multi-arch (linux/amd64 + linux/arm64 manifest list). The legacy in-process docker build is preserved for the empty output_directory case. Re-pin core to v0.3.6 for the recipe contract (output_directory, DockerBuildPlan, WithBuildPlan, BuildDockerBuildPlan). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c29b401 commit e68ed92

4 files changed

Lines changed: 138 additions & 4 deletions

File tree

‎build_recipe_test.go‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"path"
8+
"testing"
9+
"time"
10+
11+
"github.com/codefly-dev/core/agents/services"
12+
basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0"
13+
builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0"
14+
"github.com/codefly-dev/core/resources"
15+
"github.com/codefly-dev/core/shared"
16+
"github.com/stretchr/testify/require"
17+
)
18+
19+
// createdBuilder loads and creates a fastapi service in a fresh temp directory
20+
// and returns the builder together with its service location.
21+
func createdBuilder(t *testing.T) (*Builder, string) {
22+
t.Helper()
23+
ctx := context.Background()
24+
25+
tmpDir, err := os.MkdirTemp("testdata", "recipe")
26+
require.NoError(t, err)
27+
tmpDir = shared.MustSolvePath(tmpDir)
28+
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
29+
30+
service := resources.Service{Name: fmt.Sprintf("svc-%v", time.Now().UnixMilli()), Version: "0.0.0"}
31+
require.NoError(t, service.SaveAtDir(ctx, path.Join(tmpDir, fmt.Sprintf("mod/%s", service.Name))))
32+
service.WithModule("mod")
33+
34+
identity := &basev0.ServiceIdentity{
35+
Name: service.Name,
36+
Version: service.Version,
37+
Module: "mod",
38+
Workspace: "test",
39+
WorkspacePath: tmpDir,
40+
RelativeToWorkspace: fmt.Sprintf("mod/%s", service.Name),
41+
}
42+
43+
builder := NewBuilder(NewService())
44+
_, err = builder.Load(ctx, &builderv0.LoadRequest{Identity: identity, CreationMode: &builderv0.CreationMode{Communicate: false}})
45+
require.NoError(t, err)
46+
_, err = builder.Create(ctx, &builderv0.CreateRequest{})
47+
require.NoError(t, err)
48+
49+
return builder, builder.Local("builder")
50+
}
51+
52+
func recipeBuildRequest(outputDir string) *builderv0.BuildRequest {
53+
return &builderv0.BuildRequest{
54+
BuildContext: &builderv0.BuildContext{
55+
Kind: &builderv0.BuildContext_DockerBuildContext{
56+
DockerBuildContext: &builderv0.DockerBuildContext{DockerRepository: "example.com/test"},
57+
},
58+
},
59+
OutputDirectory: outputDir,
60+
}
61+
}
62+
63+
// TestBuildEmitsRecipePlan proves that when the CLI provides an output
64+
// directory, Build renders the Dockerfile there and returns a DockerBuildPlan
65+
// (not the legacy DockerBuildResult) whose recipe the CLI executor can consume:
66+
// the Dockerfile is resolved relative to the output directory, the context to
67+
// the service directory, and the image targets a multi-arch manifest list.
68+
func TestBuildEmitsRecipePlan(t *testing.T) {
69+
builder, outputDir := createdBuilder(t)
70+
71+
resp, err := builder.Build(context.Background(), recipeBuildRequest(outputDir))
72+
require.NoError(t, err)
73+
require.Equal(t, builderv0.BuildStatus_SUCCESS, resp.GetState().GetState(), resp.GetState().GetMessage())
74+
75+
plan := resp.GetResult().GetDockerBuildPlan()
76+
require.NotNil(t, plan, "recipe path must emit a DockerBuildPlan")
77+
require.Nil(t, resp.GetResult().GetDockerBuildResult(), "recipe path must not emit a legacy DockerBuildResult")
78+
79+
require.Len(t, plan.GetRecipes(), 1)
80+
recipe := plan.GetRecipes()[0]
81+
require.Equal(t, "Dockerfile", recipe.GetDockerfile())
82+
require.Equal(t, ".", recipe.GetContext())
83+
require.Equal(t, []string{"linux/amd64", "linux/arm64"}, recipe.GetPlatforms())
84+
require.NotEmpty(t, recipe.GetImage())
85+
86+
// The plan must verify against the on-disk tree exactly as the CLI verifies
87+
// it before running docker buildx.
88+
require.NoError(t, services.VerifyDockerBuildPlan(outputDir, plan))
89+
90+
// The Dockerfile the CLI builds must actually exist in the emitted tree.
91+
_, err = os.Stat(path.Join(outputDir, recipe.GetDockerfile()))
92+
require.NoError(t, err)
93+
}

‎builder.go‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"embed"
66
"errors"
77
"os"
8+
"path/filepath"
89

910
"github.com/codefly-dev/core/agents/communicate"
1011
dockerhelpers "github.com/codefly-dev/core/agents/helpers/docker"
@@ -146,7 +147,8 @@ type DockerTemplating struct {
146147
}
147148

148149
// Build produces the service Docker image. Generic is a no-op; fastapi
149-
// renders a Dockerfile and runs docker build.
150+
// renders a Dockerfile and either builds it in-process (legacy) or, when the
151+
// CLI owns the build, emits a reproducible recipe for the CLI to build.
150152
func (s *Builder) Build(ctx context.Context, req *builderv0.BuildRequest) (*builderv0.BuildResponse, error) {
151153
defer s.Wool.Catch()
152154
dockerRequest, err := s.Base.Builder.DockerBuildRequest(ctx, req)
@@ -170,6 +172,19 @@ func (s *Builder) Build(ctx context.Context, req *builderv0.BuildRequest) (*buil
170172
return nil, s.Wool.Wrapf(err, "cannot copy and apply template")
171173
}
172174

175+
// When the caller owns the build (a non-empty output_directory), emit a
176+
// reproducible build recipe rather than building in-process. The rendered
177+
// Dockerfile already lives in that directory, so the CLI runs docker buildx
178+
// against it and publishes a multi-arch manifest list.
179+
if outputDir := req.GetOutputDirectory(); outputDir != "" {
180+
plan, err := singleImageBuildPlan(outputDir, image.FullName())
181+
if err != nil {
182+
return s.Base.Builder.BuildError(err)
183+
}
184+
s.Base.Builder.WithBuildPlan(plan)
185+
return s.Base.Builder.BuildResponse()
186+
}
187+
173188
builder, err := dockerhelpers.NewBuilder(dockerhelpers.BuilderConfiguration{
174189
Root: s.Location,
175190
Dockerfile: "builder/Dockerfile",
@@ -187,6 +202,32 @@ func (s *Builder) Build(ctx context.Context, req *builderv0.BuildRequest) (*buil
187202
return s.Base.Builder.BuildResponse()
188203
}
189204

205+
// singleImageBuildPlan inventories the recipe the CLI-owned build consumes: the
206+
// builder/Dockerfile rendered into outputDirectory, built with the service
207+
// directory as its context and targeting a linux/amd64 + linux/arm64 manifest
208+
// list so a consumer never needs the agent toolchain to rebuild. Paths are
209+
// encoded as the CLI's recipe executor resolves them — the Dockerfile relative
210+
// to outputDirectory (which the CLI sets to <service>/builder), the context
211+
// relative to the service directory.
212+
//
213+
// This stands in for services.SingleImageBuildPlan / services.RecipeBuildPlatforms,
214+
// which are not in a released core yet (they land with the shared recipe runners
215+
// in core#336); it can be replaced once that release lands and its recipe layout
216+
// is confirmed to match the CLI executor.
217+
func singleImageBuildPlan(outputDirectory, image string) (*builderv0.DockerBuildPlan, error) {
218+
recipe := &builderv0.DockerBuildRecipe{
219+
Name: "app",
220+
Dockerfile: "Dockerfile",
221+
Context: ".",
222+
Image: image,
223+
Platforms: []string{"linux/amd64", "linux/arm64"},
224+
}
225+
if info, err := os.Stat(filepath.Join(outputDirectory, "dockerignore")); err == nil && info.Mode().IsRegular() {
226+
recipe.Dockerignore = "dockerignore"
227+
}
228+
return services.BuildDockerBuildPlan(outputDirectory, []*builderv0.DockerBuildRecipe{recipe})
229+
}
230+
190231
// Upgrade bumps Python dependencies in requirements.txt (pip list
191232
// --outdated + rewrite + pip install --upgrade). --major allows major
192233
// version jumps; --dry-run skips the write.

‎go.mod‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ module github.com/codefly-dev/service-python-fastapi
33
go 1.27.0
44

55
require (
6-
github.com/codefly-dev/core v0.3.4
6+
github.com/codefly-dev/core v0.3.6
77
github.com/codefly-dev/service-python v0.0.52
88
github.com/stretchr/testify v1.11.1
99
google.golang.org/grpc v1.83.0

‎go.sum‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
4141
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
4242
github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA=
4343
github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c=
44-
github.com/codefly-dev/core v0.3.4 h1:gTpAvIKb3sY8gOESkXDuKsPvIjG9d55kY5rGoVQR/YE=
45-
github.com/codefly-dev/core v0.3.4/go.mod h1:ca5IAJnFJSC3OPl5DcoRIi41BMfO0As8BaDGciYZ7nI=
44+
github.com/codefly-dev/core v0.3.6 h1:lt5EyL+uS1Z6Vzg+V/HhpnIV43Lu5rN9E0k+ZslIKHk=
45+
github.com/codefly-dev/core v0.3.6/go.mod h1:ca5IAJnFJSC3OPl5DcoRIi41BMfO0As8BaDGciYZ7nI=
4646
github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ=
4747
github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg=
4848
github.com/codefly-dev/service-python v0.0.52 h1:DY5IBBAmlna4Ll5XL8md5QvOqeB6BGA7mfPQsqfC1mw=

0 commit comments

Comments
 (0)