@@ -3,7 +3,9 @@ package main
33import (
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}
0 commit comments