Skip to content

Commit b4d45f1

Browse files
fix: return CLI-owned image build recipes (#9)
1 parent 15103dd commit b4d45f1

6 files changed

Lines changed: 111 additions & 102 deletions

File tree

‎agent.codefly.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
publisher: codefly.dev
22
kind: codefly:service
33
name: python-fastapi
4-
version: 0.0.97
4+
version: 0.0.98

‎build_recipe_test.go‎

Lines changed: 88 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ package main
33
import (
44
"context"
55
"fmt"
6+
"net"
67
"os"
8+
"os/exec"
79
"path"
810
"testing"
911
"time"
@@ -14,6 +16,8 @@ import (
1416
"github.com/codefly-dev/core/resources"
1517
"github.com/codefly-dev/core/shared"
1618
"github.com/stretchr/testify/require"
19+
"google.golang.org/grpc"
20+
"google.golang.org/grpc/credentials/insecure"
1721
)
1822

1923
// createdBuilder loads and creates a fastapi service in a fresh temp directory
@@ -60,34 +64,91 @@ func recipeBuildRequest(outputDir string) *builderv0.BuildRequest {
6064
}
6165
}
6266

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))
67+
func recipeClient(t *testing.T, builder *Builder) *services.BuilderAgent {
68+
t.Helper()
69+
listener, err := net.Listen("tcp", "127.0.0.1:0")
7270
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())
71+
server := grpc.NewServer()
72+
builderv0.RegisterBuilderServer(server, builder)
73+
go func() { _ = server.Serve(listener) }()
74+
t.Cleanup(server.Stop)
75+
conn, err := grpc.NewClient(listener.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials()))
76+
require.NoError(t, err)
77+
t.Cleanup(func() { require.NoError(t, conn.Close()) })
78+
return services.NewBuilderAgentClient(conn)
79+
}
8580

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))
81+
func TestBuildEmitsRecipePlan(t *testing.T) {
82+
for _, selection := range []string{"default", "explicit", "cache-selected"} {
83+
t.Run(selection, func(t *testing.T) {
84+
builder, conventionalDir := createdBuilder(t)
85+
outputDir := t.TempDir()
86+
client := recipeClient(t, builder)
87+
t.Setenv("PATH", t.TempDir())
88+
for _, executable := range []string{"docker", "buildx"} {
89+
_, err := exec.LookPath(executable)
90+
require.Error(t, err)
91+
}
92+
req := recipeBuildRequest(outputDir)
93+
docker := req.GetBuildContext().GetDockerBuildContext()
94+
if selection != "default" {
95+
docker.BuildxBuilder = "cli-selected"
96+
}
97+
if selection == "cache-selected" {
98+
docker.Cache = &builderv0.BuildCacheOptions{Backend: "registry", Scope: "service", Imports: []string{"example.com/test/cache"}, Exports: []string{"example.com/test/cache"}}
99+
}
100+
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
101+
defer cancel()
102+
capabilities, err := client.BuildCapabilities(ctx, &builderv0.BuildCapabilitiesRequest{})
103+
require.NoError(t, err)
104+
require.True(t, capabilities.GetBuildxSelection())
105+
resp, err := client.Build(ctx, req)
106+
require.NoError(t, err)
107+
require.Equal(t, builderv0.BuildStatus_SUCCESS, resp.GetState().GetState(), resp.GetState().GetMessage())
108+
require.Empty(t, resp.GetBuildxBuilder())
109+
require.Empty(t, resp.GetCacheContractVersion())
110+
plan := resp.GetResult().GetDockerBuildPlan()
111+
require.NotNil(t, plan)
112+
require.Nil(t, resp.GetResult().GetDockerBuildResult())
113+
require.Len(t, plan.GetRecipes(), 1)
114+
recipe := plan.GetRecipes()[0]
115+
require.Equal(t, "Dockerfile", recipe.GetDockerfile())
116+
require.Equal(t, ".", recipe.GetContext())
117+
require.Equal(t, []string{"linux/amd64", "linux/arm64"}, recipe.GetPlatforms())
118+
require.NotEmpty(t, recipe.GetImage())
119+
require.NoError(t, services.VerifyDockerBuildPlan(outputDir, plan))
120+
require.NoFileExists(t, path.Join(conventionalDir, "Dockerfile"))
121+
content, err := os.ReadFile(path.Join(outputDir, recipe.GetDockerfile()))
122+
require.NoError(t, err)
123+
require.Contains(t, string(content), "COPY code/pyproject.toml code/uv.lock ./")
124+
require.Contains(t, string(content), "COPY --chown=appuser code/src code/src")
125+
require.NoError(t, os.WriteFile(path.Join(outputDir, "Dockerfile"), []byte("tampered"), 0600))
126+
require.Error(t, services.VerifyDockerBuildPlan(outputDir, plan))
127+
})
128+
}
129+
}
89130

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)
131+
func TestBuildRejectsInvalidOutputDirectoryBeforePreparation(t *testing.T) {
132+
for _, outputDir := range []string{"", "relative"} {
133+
t.Run(fmt.Sprintf("output=%q", outputDir), func(t *testing.T) {
134+
builder, conventionalDir := createdBuilder(t)
135+
require.NoError(t, os.MkdirAll(conventionalDir, 0755))
136+
dockerfile := path.Join(conventionalDir, "Dockerfile")
137+
require.NoError(t, os.WriteFile(dockerfile, []byte("untouched"), 0600))
138+
client := recipeClient(t, builder)
139+
t.Setenv("PATH", t.TempDir())
140+
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
141+
defer cancel()
142+
req := recipeBuildRequest(outputDir)
143+
req.GetBuildContext().GetDockerBuildContext().BuildxBuilder = "cli-selected"
144+
resp, err := client.Build(ctx, req)
145+
require.NoError(t, err)
146+
require.Equal(t, builderv0.BuildStatus_ERROR, resp.GetState().GetState())
147+
require.Contains(t, resp.GetState().GetMessage(), "output_directory")
148+
require.Nil(t, resp.GetResult())
149+
content, err := os.ReadFile(dockerfile)
150+
require.NoError(t, err)
151+
require.Equal(t, "untouched", string(content))
152+
})
153+
}
93154
}

‎builder.go‎

Lines changed: 15 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"path/filepath"
99

1010
"github.com/codefly-dev/core/agents/communicate"
11-
dockerhelpers "github.com/codefly-dev/core/agents/helpers/docker"
1211
"github.com/codefly-dev/core/agents/services"
1312
"github.com/codefly-dev/core/agents/services/upgrade"
1413
"github.com/codefly-dev/core/companions/proto"
@@ -39,7 +38,7 @@ import (
3938
// Overridden: Load (fastapi puts source under ./code, discovers REST
4039
// endpoint), Update (applies builder templates), Sync (gRPC codegen for
4140
// declared dependencies and the optional service-owned gRPC server), Build
42-
// (custom DockerTemplating + docker build),
41+
// (Dockerfile and build plan preparation),
4342
// Deploy (k8s), Create (two-question Communicate + REST endpoint).
4443
type Builder struct {
4544
*pythonbuilder.Builder
@@ -170,86 +169,32 @@ type DockerTemplating struct {
170169
Envs []Env
171170
}
172171

173-
// Build produces the service Docker image. Generic is a no-op; fastapi
174-
// renders a Dockerfile and either builds it in-process (legacy) or, when the
175-
// CLI owns the build, emits a reproducible recipe for the CLI to build.
172+
func (s *Builder) BuildCapabilities(context.Context, *builderv0.BuildCapabilitiesRequest) (*builderv0.BuildCapabilitiesResponse, error) {
173+
return &builderv0.BuildCapabilitiesResponse{BuildxSelection: true}, nil
174+
}
175+
176176
func (s *Builder) Build(ctx context.Context, req *builderv0.BuildRequest) (*builderv0.BuildResponse, error) {
177177
defer s.Wool.Catch()
178+
if req.GetOutputDirectory() == "" {
179+
return s.Base.Builder.BuildError(errors.New("output_directory is required for CLI image builds"))
180+
}
178181
dockerRequest, err := s.Base.Builder.DockerBuildRequest(ctx, req)
179182
if err != nil {
180-
return nil, s.Wool.Wrapf(err, "can only do docker build request")
183+
return s.Base.Builder.BuildError(err)
181184
}
182-
183-
image := s.DockerImage(dockerRequest)
184-
s.Wool.Debug("building docker image", wool.Field("image", image.FullName()))
185185
ctx = s.Wool.Inject(ctx)
186-
187186
docker := DockerTemplating{
188187
Builder: runtimeImage.FullName(),
189188
Components: requirements.All(),
190189
}
191-
192-
if err := shared.DeleteFile(ctx, s.Local("builder/Dockerfile")); err != nil {
193-
return nil, s.Wool.Wrapf(err, "cannot remove dockerfile")
194-
}
195-
if err := s.Base.Templates(ctx, docker, services.WithBuilder(builderFS)); err != nil {
196-
return nil, s.Wool.Wrapf(err, "cannot copy and apply template")
197-
}
198-
199-
// When the caller owns the build (a non-empty output_directory), emit a
200-
// reproducible build recipe rather than building in-process. The rendered
201-
// Dockerfile already lives in that directory, so the CLI runs docker buildx
202-
// against it and publishes a multi-arch manifest list.
203-
if outputDir := req.GetOutputDirectory(); outputDir != "" {
204-
plan, err := singleImageBuildPlan(outputDir, image.FullName())
205-
if err != nil {
206-
return s.Base.Builder.BuildError(err)
207-
}
208-
s.Base.Builder.WithBuildPlan(plan)
209-
return s.Base.Builder.BuildResponse()
210-
}
211-
212-
builder, err := dockerhelpers.NewBuilder(dockerhelpers.BuilderConfiguration{
213-
Root: s.Location,
214-
Dockerfile: "builder/Dockerfile",
215-
Destination: image,
216-
Output: s.Wool,
217-
})
218-
if err != nil {
219-
return nil, s.Wool.Wrapf(err, "cannot create builder")
190+
outputDir := req.GetOutputDirectory()
191+
if err := shared.DeleteFile(ctx, filepath.Join(outputDir, "Dockerfile")); err != nil {
192+
return s.Base.Builder.BuildError(err)
220193
}
221-
if _, err := builder.Build(ctx); err != nil {
222-
return nil, s.Wool.Wrapf(err, "cannot build image")
194+
if err := s.Base.Templates(ctx, docker, services.WithBuilder(builderFS).WithDestination("%s", outputDir)); err != nil {
195+
return s.Base.Builder.BuildError(err)
223196
}
224-
225-
s.Base.Builder.WithDockerImages(image)
226-
return s.Base.Builder.BuildResponse()
227-
}
228-
229-
// singleImageBuildPlan inventories the recipe the CLI-owned build consumes: the
230-
// builder/Dockerfile rendered into outputDirectory, built with the service
231-
// directory as its context and targeting a linux/amd64 + linux/arm64 manifest
232-
// list so a consumer never needs the agent toolchain to rebuild. Paths are
233-
// encoded as the CLI's recipe executor resolves them — the Dockerfile relative
234-
// to outputDirectory (which the CLI sets to <service>/builder), the context
235-
// relative to the service directory.
236-
//
237-
// This stands in for services.SingleImageBuildPlan / services.RecipeBuildPlatforms,
238-
// which are not in a released core yet (they land with the shared recipe runners
239-
// in core#336); it can be replaced once that release lands and its recipe layout
240-
// is confirmed to match the CLI executor.
241-
func singleImageBuildPlan(outputDirectory, image string) (*builderv0.DockerBuildPlan, error) {
242-
recipe := &builderv0.DockerBuildRecipe{
243-
Name: "app",
244-
Dockerfile: "Dockerfile",
245-
Context: ".",
246-
Image: image,
247-
Platforms: []string{"linux/amd64", "linux/arm64"},
248-
}
249-
if info, err := os.Stat(filepath.Join(outputDirectory, "dockerignore")); err == nil && info.Mode().IsRegular() {
250-
recipe.Dockerignore = "dockerignore"
251-
}
252-
return services.BuildDockerBuildPlan(outputDirectory, []*builderv0.DockerBuildRecipe{recipe})
197+
return s.Base.Builder.SingleImageBuildResponse(req, s.DockerImage(dockerRequest).FullName())
253198
}
254199

255200
// Upgrade bumps Python dependencies in requirements.txt (pip list

‎go.mod‎

Lines changed: 2 additions & 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.11
6+
github.com/codefly-dev/core v0.3.27
77
github.com/codefly-dev/service-python v0.0.61
88
github.com/stretchr/testify v1.11.1
99
google.golang.org/grpc v1.83.0
@@ -88,6 +88,7 @@ require (
8888
github.com/mattn/go-runewidth v0.0.28 // indirect
8989
github.com/mitchellh/mapstructure v1.5.0 // indirect
9090
github.com/moby/docker-image-spec v1.3.1 // indirect
91+
github.com/moby/patternmatcher v0.6.0 // indirect
9192
github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect
9293
github.com/oklog/ulid/v2 v2.1.2 // indirect
9394
github.com/opencontainers/go-digest v1.0.0 // indirect

‎go.sum‎

Lines changed: 4 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.11 h1:FPYNQ3MysUeuZmXISBl7G2PEkzijE0wBWen2sF8pqMc=
45-
github.com/codefly-dev/core v0.3.11/go.mod h1:4V8BP4F5mnRDZvylhC9P5N7rfifpAma5n3dW4YD+gtQ=
44+
github.com/codefly-dev/core v0.3.27 h1:SI0EhUvGr+WlIkuLEuTDQuMjZX5ThgmPGJO2f0NPyzM=
45+
github.com/codefly-dev/core v0.3.27/go.mod h1:Z9//zS5LnN1MdX5Oi9AuPkXOHYSfzi25GyPi0JU044w=
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.61 h1:kiS7nC4vqXNJ4tajpwj42uIjUxIMUDcLuJSOlt0vFuw=
@@ -200,6 +200,8 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua
200200
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
201201
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
202202
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
203+
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
204+
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
203205
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
204206
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
205207
github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=

‎templates/agent/README.md.tmpl‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,5 @@ Some of the things this agent provides for you:
1111
- OpenAPI
1212

1313
## Production ready
14-
- docker build
14+
- Dockerfile and verifiable build plan generation (requires `output_directory`); the CLI owns image builds, builder selection, caching, and publishing
1515
- Kubernetes deployment

0 commit comments

Comments
 (0)