diff --git a/cmd/uncloud/build.go b/cmd/uncloud/build.go index 82e2cd9a..22971fa1 100644 --- a/cmd/uncloud/build.go +++ b/cmd/uncloud/build.go @@ -66,7 +66,10 @@ func runBuild(ctx context.Context, uncli *cli.CLI, opts cli.BuildOptions) error } } - servicesToBuild := cli.GetServicesThatNeedBuild(project) + servicesToBuild, err := cli.ServicesThatNeedBuild(project, opts.Services, false) + if err != nil { + return fmt.Errorf("determine services to build: %w", err) + } if len(servicesToBuild) == 0 { fmt.Println("No services to build.") diff --git a/cmd/uncloud/cbuild.go b/cmd/uncloud/cbuild.go index 86c4eaaf..da4a768c 100644 --- a/cmd/uncloud/cbuild.go +++ b/cmd/uncloud/cbuild.go @@ -17,6 +17,8 @@ import ( type buildOptions struct { buildArgs []string + check bool + deps bool files []string noCache bool profiles []string @@ -44,10 +46,14 @@ func NewCBuildCommand() *cobra.Command { } cmd.Flags().StringArrayVar(&opts.buildArgs, "build-arg", nil, - "Set a build-time variable to pass to service Dockerfiles. Only used if declared with ARG.\n"+ + "Set a build-time variable for services. Used in Dockerfiles that declare the variable with ARG.\n"+ "Can be specified multiple times. Format: --build-arg VAR=VALUE") + cmd.Flags().BoolVar(&opts.check, "check", false, + "Check the build configuration for services without building them.") + cmd.Flags().BoolVar(&opts.deps, "deps", false, + "Also build services declared as dependencies of the selected services.") cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil, - "One or more Compose files to build (default compose.yaml)") + "One or more Compose files to build. (default compose.yaml)") cmd.Flags().BoolVar(&opts.noCache, "no-cache", false, "Do not use cache when building images.") cmd.Flags().StringSliceVarP(&opts.profiles, "profile", "p", nil, @@ -60,32 +66,27 @@ func NewCBuildCommand() *cobra.Command { // projectOptsFromCBuildOpts returns the project options for the Compose file(s). func projectOptsFromCBuildOpts(opts buildOptions) []composecli.ProjectOptionsFn { - var projectOpts []composecli.ProjectOptionsFn + var projOpts []composecli.ProjectOptionsFn if len(opts.profiles) > 0 { - projectOpts = append(projectOpts, composecli.WithDefaultProfiles(opts.profiles...)) + projOpts = append(projOpts, composecli.WithDefaultProfiles(opts.profiles...)) } - return projectOpts + return projOpts } // runCBuild parses the Compose file(s) and builds the images for selected services. func runCBuild(ctx context.Context, _ *cli.CLI, opts buildOptions) error { - projectOpts := projectOptsFromCBuildOpts(opts) - project, err := compose.LoadProject(ctx, opts.files, projectOpts...) + projOpts := projectOptsFromCBuildOpts(opts) + project, err := compose.LoadProject(ctx, opts.files, projOpts...) if err != nil { return fmt.Errorf("load compose file(s): %w", err) } - if len(opts.services) > 0 { - project, err = project.WithSelectedServices(opts.services) - if err != nil { - return fmt.Errorf("select services: %w", err) - } + servicesToBuild, err := cli.ServicesThatNeedBuild(project, opts.services, opts.deps) + if err != nil { + return fmt.Errorf("determine services to build: %w", err) } - - servicesToBuild := cli.GetServicesThatNeedBuild(project) - if len(servicesToBuild) == 0 { fmt.Println("No services to build.") return nil @@ -105,6 +106,8 @@ func runCBuild(ctx context.Context, _ *cli.CLI, opts buildOptions) error { composeService := composev2.NewComposeService(dockerCli) buildOpts := composeapi.BuildOptions{ Args: composetypes.NewMappingWithEquals(opts.buildArgs), + Check: opts.check, + Deps: opts.deps, NoCache: opts.noCache, Pull: opts.pull, Services: opts.services, @@ -114,7 +117,5 @@ func runCBuild(ctx context.Context, _ *cli.CLI, opts buildOptions) error { return fmt.Errorf("build services: %w", err) } - fmt.Println("Build completed successfully.") - return nil } diff --git a/cmd/uncloud/deploy.go b/cmd/uncloud/deploy.go index 0dee3068..f8214e66 100644 --- a/cmd/uncloud/deploy.go +++ b/cmd/uncloud/deploy.go @@ -68,20 +68,20 @@ func NewDeployCommand() *cobra.Command { // projectOpts returns the project options for the Compose file(s). func projectOpts(opts deployOptions) []composecli.ProjectOptionsFn { - projectOpts := []composecli.ProjectOptionsFn{} + var projOpts []composecli.ProjectOptionsFn if len(opts.profiles) > 0 { - projectOpts = append(projectOpts, composecli.WithDefaultProfiles(opts.profiles...)) + projOpts = append(projOpts, composecli.WithDefaultProfiles(opts.profiles...)) } - return projectOpts + return projOpts } // runDeploy parses the Compose file(s) and deploys the services. func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error { - projectOpts := projectOpts(opts) + projOpts := projectOpts(opts) - project, err := compose.LoadProject(ctx, opts.files, projectOpts...) + project, err := compose.LoadProject(ctx, opts.files, projOpts...) if err != nil { return fmt.Errorf("load compose file(s): %w", err) } @@ -94,7 +94,10 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error { } } - servicesToBuild := cli.GetServicesThatNeedBuild(project) + servicesToBuild, err := cli.ServicesThatNeedBuild(project, opts.services, false) + if err != nil { + return fmt.Errorf("determine services to build: %w", err) + } if len(servicesToBuild) > 0 { if opts.noBuild { diff --git a/internal/cli/build.go b/internal/cli/build.go index e89d50c6..49787b07 100644 --- a/internal/cli/build.go +++ b/internal/cli/build.go @@ -6,8 +6,10 @@ import ( "encoding/json" "fmt" "os" + "strings" composetypes "github.com/compose-spec/compose-go/v2/types" + mapset "github.com/deckarep/golang-set/v2" "github.com/distribution/reference" "github.com/docker/cli/cli/config" "github.com/docker/docker/api/types/build" @@ -27,16 +29,71 @@ type BuildOptions struct { NoCache bool } -// GetServicesThatNeedBuild returns a map of services that require building -func GetServicesThatNeedBuild(project *composetypes.Project) map[string]composetypes.ServiceConfig { +// ServicesThatNeedBuild returns a map of services that require building. +// deps indicates whether to include services that are dependencies of the selected services. +// Implementation is based on the logic from docker/compose/v2/pkg/compose/build.go. +func ServicesThatNeedBuild( + project *composetypes.Project, selectedServices []string, deps bool, +) (map[string]composetypes.ServiceConfig, error) { servicesToBuild := make(map[string]composetypes.ServiceConfig, len(project.Services)) - for serviceName, service := range project.Services { - if service.Build == nil { - continue - } - servicesToBuild[serviceName] = service + + var policy composetypes.DependencyOption = composetypes.IgnoreDependencies + if deps { + policy = composetypes.IncludeDependencies } - return servicesToBuild + + // Also include services used as build.additional_contexts with service: prefix. + selectedServices = includeAdditionalContextsServices(project, selectedServices) + // Some build dependencies we just introduced may not be enabled, enable them. + var err error + project, err = project.WithServicesEnabled(selectedServices...) + if err != nil { + return nil, err + } + + project, err = project.WithSelectedServices(selectedServices) + if err != nil { + return nil, err + } + + err = project.ForEachService(selectedServices, func(serviceName string, service *composetypes.ServiceConfig) error { + if service.Build != nil { + servicesToBuild[serviceName] = *service + } + return nil + }, policy) + + for serviceName, service := range project.Services { + if service.Build != nil { + servicesToBuild[serviceName] = service + } + } + + return servicesToBuild, nil +} + +// includeAdditionalContextsServices adds services referenced in build.additional_contexts to the list +// of selected services. +func includeAdditionalContextsServices(project *composetypes.Project, selectedServices []string) []string { + servicesWithDependencies := mapset.NewSet(selectedServices...) + for _, service := range selectedServices { + s, ok := project.Services[service] + if !ok { + s = project.DisabledServices[service] + } + if s.Build != nil { + for _, target := range s.Build.AdditionalContexts { + if name, found := strings.CutPrefix(target, composetypes.ServicePrefix); found { + servicesWithDependencies.Add(name) + } + } + } + } + if servicesWithDependencies.Cardinality() > len(selectedServices) { + return includeAdditionalContextsServices(project, servicesWithDependencies.ToSlice()) + } + + return servicesWithDependencies.ToSlice() } // BuildServices builds the services defined in the provided map. diff --git a/test/e2e/compose_build_test.go b/test/e2e/compose_build_test.go index 18c3648a..87a035df 100644 --- a/test/e2e/compose_build_test.go +++ b/test/e2e/compose_build_test.go @@ -97,7 +97,8 @@ func TestComposeBuild(t *testing.T) { }), ) require.NoError(t, err) - servicesToBuild := cliInternal.GetServicesThatNeedBuild(project) + servicesToBuild, err := cliInternal.ServicesThatNeedBuild(project, nil, false) + require.NoError(t, err) serviceImage1 := fmt.Sprintf("127.0.0.1:%d/service-first", registryHostPort) serviceImage2 := fmt.Sprintf("127.0.0.1:%d/service-second:version2", registryHostPort) t.Cleanup(func() {