diff --git a/cmd/uncloud/build.go b/cmd/uncloud/build.go index 22971fa1..e31062b6 100644 --- a/cmd/uncloud/build.go +++ b/cmd/uncloud/build.go @@ -10,71 +10,103 @@ import ( "github.com/spf13/cobra" ) -// NewBuildCommand creates a new command to build services from a Compose file. +type buildOptions struct { + cli.BuildServicesOptions + files []string + profiles []string +} + +// NewBuildCommand creates a new command to build images for services from a Compose file. func NewBuildCommand() *cobra.Command { - opts := cli.BuildOptions{} + opts := buildOptions{} cmd := &cobra.Command{ Use: "build [FLAGS] [SERVICE...]", Short: "Build services from a Compose file.", + Long: `Build images for services from a Compose file using local Docker. + +By default, built images remain on the local Docker host. Use --push to upload them +to cluster machines or --push-registry to upload them to external registries.`, + Example: ` # Build all services that have a build section in compose.yaml. + uc build + + # Build specific services that have a build section. + uc build web api + + # Build services and push images to all cluster machines or service x-machines if specified. + uc build --push + + # Build services and push images to specific machines. + uc build --push -m machine1,machine2 + + # Build services and push images to external registries (e.g., Docker Hub). + uc build --push-registry + + # Build services with build arguments, pull newer base images before building, and don't use cache. + uc build --build-arg NODE_VERSION=24 --build-arg ENV=production --no-cache --pull`, RunE: func(cmd *cobra.Command, args []string) error { uncli := cmd.Context().Value("cli").(*cli.CLI) - - if len(args) > 0 { - opts.Services = args - } + opts.Services = args return runBuild(cmd.Context(), uncli, opts) }, } - cmd.Flags().StringSliceVarP(&opts.Files, "file", "f", nil, - "One or more Compose files to build (default compose.yaml)") - cmd.Flags().StringSliceVarP(&opts.Profiles, "profile", "p", nil, - "One or more Compose profiles to enable.") - cmd.Flags().BoolVarP(&opts.Push, "push", "P", false, - "Push built images to the registry after building. (default false)") + cmd.Flags().StringArrayVar(&opts.BuildArgs, "build-arg", nil, + "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)") + cmd.Flags().StringSliceVarP(&opts.Machines, "machine", "m", nil, + "Machine names or IDs to push the built images to (requires --push).\n"+ + "Can be specified multiple times or as a comma-separated list. (default is all machines or x-machines)") cmd.Flags().BoolVar(&opts.NoCache, "no-cache", false, - "Do not use cache when building images. (default false)") + "Do not use cache when building images.") + cmd.Flags().StringSliceVarP(&opts.profiles, "profile", "p", nil, + "One or more Compose profiles to enable.") + cmd.Flags().BoolVar(&opts.Pull, "pull", false, + "Always attempt to pull newer versions of base images before building.") + cmd.Flags().BoolVar(&opts.PushCluster, "push", false, + "Upload the built images to cluster machines after building.\n"+ + "Use --machine to specify which machines. (default is all machines)") + cmd.Flags().BoolVar(&opts.PushRegistry, "push-registry", false, + "Upload the built images to external registries (e.g., Docker Hub) after building.") + cmd.Flags().StringVarP(&opts.Context, "context", "c", "", + "Name of the cluster context. (default is the current context)") return cmd } -// TODO: deduplicate with a similar functino for deploy options -// projectOpts returns the project options for the Compose file(s). -func projectOptsFromBuildOpts(opts cli.BuildOptions) []composecli.ProjectOptionsFn { - projectOpts := []composecli.ProjectOptionsFn{} - - if len(opts.Profiles) > 0 { - projectOpts = append(projectOpts, composecli.WithDefaultProfiles(opts.Profiles...)) +// runBuild parses the Compose file(s) and builds the images for selected services. +func runBuild(ctx context.Context, uncli *cli.CLI, opts buildOptions) error { + // Validate push flags. + if opts.PushCluster && opts.PushRegistry { + return fmt.Errorf("cannot specify both --push and --push-registry: choose one push target") } - return projectOpts -} + machines := cli.ExpandCommaSeparatedValues(opts.Machines) + // Special handling for an explicit "all" keyword to push to all machines. + if len(machines) == 1 && machines[0] == "all" { + machines = nil + } + opts.Machines = machines -// runBuild parses the Compose file(s), builds the services, and pushes them if requested. -func runBuild(ctx context.Context, uncli *cli.CLI, opts cli.BuildOptions) error { - projectOpts := projectOptsFromBuildOpts(opts) - project, err := compose.LoadProject(ctx, opts.Files, projectOpts...) + project, err := compose.LoadProject(ctx, opts.files, composecli.WithDefaultProfiles(opts.profiles...)) 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, false) + servicesToBuild, err := cli.ServicesThatNeedBuild(project, opts.Services, opts.Deps) if err != nil { return fmt.Errorf("determine services to build: %w", err) } - if len(servicesToBuild) == 0 { fmt.Println("No services to build.") return nil } - return cli.BuildServices(ctx, servicesToBuild, opts) + return uncli.BuildServices(ctx, project, opts.BuildServicesOptions) } diff --git a/cmd/uncloud/cbuild.go b/cmd/uncloud/cbuild.go deleted file mode 100644 index a4aa3b9f..00000000 --- a/cmd/uncloud/cbuild.go +++ /dev/null @@ -1,203 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - - composecli "github.com/compose-spec/compose-go/v2/cli" - composetypes "github.com/compose-spec/compose-go/v2/types" - "github.com/docker/cli/cli/command" - "github.com/docker/cli/cli/flags" - composeapi "github.com/docker/compose/v2/pkg/api" - composev2 "github.com/docker/compose/v2/pkg/compose" - "github.com/docker/compose/v2/pkg/progress" - "github.com/psviderski/uncloud/internal/cli" - "github.com/psviderski/uncloud/pkg/client" - "github.com/psviderski/uncloud/pkg/client/compose" - "github.com/spf13/cobra" -) - -type buildOptions struct { - buildArgs []string - check bool - deps bool - files []string - machines []string - noCache bool - profiles []string - pull bool - push bool - pushRegistry bool - services []string - context string -} - -// NewCBuildCommand creates a new command to build images for services from a Compose file. -func NewCBuildCommand() *cobra.Command { - opts := buildOptions{} - cmd := &cobra.Command{ - Use: "cbuild [FLAGS] [SERVICE...]", - Short: "Build services from a Compose file.", - Long: "Build images for services from a Compose file using Docker.", - Hidden: true, - RunE: func(cmd *cobra.Command, args []string) error { - uncli := cmd.Context().Value("cli").(*cli.CLI) - - if len(args) > 0 { - opts.services = args - } - - return runCBuild(cmd.Context(), uncli, opts) - }, - } - - cmd.Flags().StringArrayVar(&opts.buildArgs, "build-arg", nil, - "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)") - cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil, - "Machine names or IDs to push the built images to (requires --push).\n"+ - "Can be specified multiple times or as a comma-separated list. (default is all machines)") - cmd.Flags().BoolVar(&opts.noCache, "no-cache", false, - "Do not use cache when building images.") - cmd.Flags().StringSliceVarP(&opts.profiles, "profile", "p", nil, - "One or more Compose profiles to enable.") - cmd.Flags().BoolVar(&opts.pull, "pull", false, - "Attempt to pull newer versions of the base images before building.") - cmd.Flags().BoolVar(&opts.push, "push", false, - "Upload the built images to cluster machines after building.\n"+ - "Use --machine to specify which machines. (default is all machines)") - cmd.Flags().BoolVar(&opts.pushRegistry, "push-registry", false, - "Upload the built images to registries after building.") - cmd.Flags().StringVarP( - &opts.context, "context", "c", "", - "Name of the cluster context. (default is the current context)", - ) - - return cmd -} - -// projectOptsFromCBuildOpts returns the project options for the Compose file(s). -func projectOptsFromCBuildOpts(opts buildOptions) []composecli.ProjectOptionsFn { - var projOpts []composecli.ProjectOptionsFn - - if len(opts.profiles) > 0 { - projOpts = append(projOpts, composecli.WithDefaultProfiles(opts.profiles...)) - } - - return projOpts -} - -// runCBuild parses the Compose file(s) and builds the images for selected services. -func runCBuild(ctx context.Context, uncli *cli.CLI, opts buildOptions) error { - // Validate push flags. - if opts.push && opts.pushRegistry { - return fmt.Errorf("cannot specify both --push and --push-registry: choose one push target") - } - - projOpts := projectOptsFromCBuildOpts(opts) - project, err := compose.LoadProject(ctx, opts.files, projOpts...) - if err != nil { - return fmt.Errorf("load compose file(s): %w", err) - } - - servicesToBuild, err := cli.ServicesThatNeedBuild(project, opts.services, opts.deps) - if err != nil { - return fmt.Errorf("determine services to build: %w", err) - } - if len(servicesToBuild) == 0 { - fmt.Println("No services to build.") - return nil - } - - // Build service images using Compose implementation. - dockerCli, err := command.NewDockerCli() - if err != nil { - return fmt.Errorf("create docker client: %w", err) - } - - // Initialise the Docker CLI with default options. - if err = dockerCli.Initialize(flags.NewClientOptions()); err != nil { - return fmt.Errorf("initialise docker client: %w", err) - } - - composeService := composev2.NewComposeService(dockerCli) - buildOpts := composeapi.BuildOptions{ - Args: composetypes.NewMappingWithEquals(opts.buildArgs), - Check: opts.check, - Deps: opts.deps, - NoCache: opts.noCache, - Pull: opts.pull, - Push: opts.pushRegistry, - Services: opts.services, - } - - if err = composeService.Build(ctx, project, buildOpts); err != nil { - return fmt.Errorf("build services: %w", err) - } - - // Push images to cluster machines if --push is specified. - if opts.push { - if err = pushImagesToCluster(ctx, uncli, servicesToBuild, opts.machines); err != nil { - return fmt.Errorf("push images to cluster: %w", err) - } - } - - return nil -} - -// pushImagesToCluster pushes the locally built Docker images for specified services to cluster machines via unregistry. -func pushImagesToCluster( - ctx context.Context, - uncli *cli.CLI, - services map[string]composetypes.ServiceConfig, - machines []string, -) error { - clusterClient, err := uncli.ConnectCluster(ctx, "") - if err != nil { - return fmt.Errorf("connect to cluster: %w", err) - } - defer clusterClient.Close() - - machines = cli.ExpandCommaSeparatedValues(machines) - pushOpts := client.PushImageOptions{} - - // Special handling for an explicit "all" keyword to push to all machines. - if len(machines) == 1 && machines[0] == "all" { - pushOpts.AllMachines = true - } else if len(machines) > 0 { - pushOpts.Machines = machines - } else { - // Default is to push to all machines in the cluster. - pushOpts.AllMachines = true - } - - // Push one service image at a time. - var errs []error - for _, s := range services { - if s.Image == "" { - // Skip services without an image name (shouldn't happen for services with build config). - continue - } - - err = progress.RunWithTitle(ctx, func(ctx context.Context) error { - if err = clusterClient.PushImage(ctx, s.Image, pushOpts); err != nil { - return fmt.Errorf("push image for service '%s': %w", s.Name, err) - } - return nil - }, uncli.ProgressOut(), fmt.Sprintf("Pushing image %s to cluster", s.Image)) - - // Collect errors to try pushing all images. - if err != nil { - errs = append(errs, err) - } - } - - return errors.Join(errs...) -} diff --git a/cmd/uncloud/deploy.go b/cmd/uncloud/deploy.go index f8214e66..29888afd 100644 --- a/cmd/uncloud/deploy.go +++ b/cmd/uncloud/deploy.go @@ -6,6 +6,7 @@ import ( "fmt" "strings" + "github.com/charmbracelet/lipgloss" composecli "github.com/compose-spec/compose-go/v2/cli" "github.com/docker/compose/v2/pkg/progress" "github.com/psviderski/uncloud/internal/cli" @@ -17,6 +18,8 @@ import ( ) type deployOptions struct { + cli.BuildServicesOptions + files []string profiles []string services []string @@ -37,21 +40,25 @@ func NewDeployCommand() *cobra.Command { cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM") uncli := cmd.Context().Value("cli").(*cli.CLI) - - if len(args) > 0 { - opts.services = args - } + opts.services = args return runDeploy(cmd.Context(), uncli, opts) }, } + cmd.Flags().StringArrayVar(&opts.BuildServicesOptions.BuildArgs, "build-arg", nil, + "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.BuildServicesOptions.Pull, "build-pull", false, + "Always attempt to pull newer versions of base images before building service images.") cmd.Flags().StringVarP(&opts.context, "context", "c", "", "Name of the cluster context to deploy to (default is the current context)") cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil, "One or more Compose files to deploy services from. (default compose.yaml)") - cmd.Flags().BoolVarP(&opts.noBuild, "no-build", "n", false, - "Do not build images before deploying services. (default false)") + cmd.Flags().BoolVar(&opts.noBuild, "no-build", false, + "Do not build new images before deploying services.") + cmd.Flags().BoolVar(&opts.BuildServicesOptions.NoCache, "no-cache", false, + "Do not use cache when building images.") cmd.Flags().StringSliceVarP(&opts.profiles, "profile", "p", nil, "One or more Compose profiles to enable.") cmd.Flags().BoolVar(&opts.recreate, "recreate", false, @@ -66,22 +73,9 @@ func NewDeployCommand() *cobra.Command { return cmd } -// projectOpts returns the project options for the Compose file(s). -func projectOpts(opts deployOptions) []composecli.ProjectOptionsFn { - var projOpts []composecli.ProjectOptionsFn - - if len(opts.profiles) > 0 { - projOpts = append(projOpts, composecli.WithDefaultProfiles(opts.profiles...)) - } - - return projOpts -} - // runDeploy parses the Compose file(s) and deploys the services. func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error { - projOpts := projectOpts(opts) - - project, err := compose.LoadProject(ctx, opts.files, projOpts...) + project, err := compose.LoadProject(ctx, opts.files, composecli.WithDefaultProfiles(opts.profiles...)) if err != nil { return fmt.Errorf("load compose file(s): %w", err) } @@ -103,15 +97,15 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error { if opts.noBuild { fmt.Println("Not building services as requested.") } else { - buildOpts := cli.BuildOptions{ - Push: true, - NoCache: false, - } + // Build service images without pushing them to cluster yet to not connect to the cluster twice. + opts.BuildServicesOptions.Deps = true // build dependencies as deploy includes them by default + opts.BuildServicesOptions.Services = opts.services - if err := cli.BuildServices(ctx, servicesToBuild, buildOpts); err != nil { + if err = uncli.BuildServices(ctx, project, opts.BuildServicesOptions); err != nil { return fmt.Errorf("build services: %w", err) } } + fmt.Println() } clusterClient, err := uncli.ConnectCluster(ctx, opts.context) @@ -120,6 +114,44 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error { } defer clusterClient.Close() + if len(servicesToBuild) > 0 && !opts.noBuild { + // Push built service images to cluster machines one at a time. + var errs []error + for _, s := range servicesToBuild { + if s.Image == "" { + // Skip services without an image name (shouldn't happen for services with build config). + continue + } + + // Push to the specified x-machines or to *all* cluster machines if not specified. + var pushOpts client.PushImageOptions + if machines, ok := s.Extensions[compose.MachinesExtensionKey].(compose.MachinesSource); ok { + pushOpts.Machines = machines + if len(machines) == 0 { + pushOpts.AllMachines = true + } + } + + boldStyle := lipgloss.NewStyle().Bold(true) + err = progress.RunWithTitle(ctx, func(ctx context.Context) error { + if err = clusterClient.PushImage(ctx, s.Image, pushOpts); err != nil { + return fmt.Errorf("push image '%s' for service '%s': %w", s.Image, s.Name, err) + } + return nil + }, uncli.ProgressOut(), fmt.Sprintf("Pushing image %s to cluster", boldStyle.Render(s.Image))) + + // Collect errors to try pushing all images. + if err != nil { + errs = append(errs, err) + } + } + + if err = errors.Join(errs...); err != nil { + return err + } + fmt.Println() + } + var strategy deploy.Strategy if opts.recreate { strategy = &deploy.RollingStrategy{ForceRecreate: true} @@ -139,7 +171,7 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error { return nil } - fmt.Println("Deployment plan:") + fmt.Println(lipgloss.NewStyle().Bold(true).Render("Deployment plan")) if err = printPlan(ctx, clusterClient, plan); err != nil { return fmt.Errorf("print deployment plan: %w", err) } diff --git a/cmd/uncloud/main.go b/cmd/uncloud/main.go index cc6512d4..82833d2b 100644 --- a/cmd/uncloud/main.go +++ b/cmd/uncloud/main.go @@ -80,7 +80,6 @@ func main() { NewDeployCommand(), NewDocsCommand(), NewBuildCommand(), - NewCBuildCommand(), NewImagesCommand(), caddy.NewRootCommand(), cmdcontext.NewRootCommand(), diff --git a/internal/cli/build.go b/internal/cli/build.go index 9941c293..36b4a6e9 100644 --- a/internal/cli/build.go +++ b/internal/cli/build.go @@ -2,40 +2,151 @@ package cli import ( "context" - "encoding/base64" - "encoding/json" + "errors" "fmt" - "os" "strings" + "github.com/charmbracelet/lipgloss" 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" - "github.com/docker/docker/api/types/image" - dockerclient "github.com/docker/docker/client" - "github.com/docker/docker/pkg/archive" - "github.com/docker/docker/pkg/jsonmessage" - "github.com/docker/docker/registry" - "github.com/moby/term" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/flags" + composeapi "github.com/docker/compose/v2/pkg/api" + composev2 "github.com/docker/compose/v2/pkg/compose" + "github.com/docker/compose/v2/pkg/progress" + "github.com/psviderski/uncloud/pkg/client" + "github.com/psviderski/uncloud/pkg/client/compose" ) -type BuildOptions struct { - Files []string - Profiles []string +// BuildServicesOptions contains options for building services in a Compose project. +type BuildServicesOptions struct { + // BuildArgs sets build-time variables for services. Used in Dockerfiles that declare variables with ARG. + BuildArgs []string + // Check the build configuration for services without building them. + Check bool + // Deps enables to also build services declared as dependencies of the selected Services. + Deps bool + // NoCache disables the use of cache when building images. + NoCache bool + // Pull attempts to pull newer versions of the base images before building. + Pull bool + // Services specifies which services to build. If empty, all services with a build config are built. Services []string - Push bool - NoCache bool + + // Push targets are mutually exclusive. + // PushCluster uploads the built images to cluster machines after building. + PushCluster bool + // PushRegistry uploads the built images to external registries after building. + PushRegistry bool + + // Cluster-specific options (only used if PushCluster is true). + // Context is the name of the cluster context. + Context string + // Machines is a list of machine names or IDs to push the image to. If empty, images are pushed to all machines. + Machines []string } -// ServicesThatNeedBuild returns a map of services that require building. +// BuildServices builds images for services in the Compose project. +func (cli *CLI) BuildServices(ctx context.Context, project *composetypes.Project, opts BuildServicesOptions) error { + // Validate push targets. + if opts.PushCluster && opts.PushRegistry { + return fmt.Errorf("cannot specify both PushCluster and PushRegistry: choose one push target") + } + + // Build service images using Compose implementation. + dockerCli, err := command.NewDockerCli() + if err != nil { + return fmt.Errorf("create docker client: %w", err) + } + // Initialise the Docker CLI with default options. + if err = dockerCli.Initialize(flags.NewClientOptions()); err != nil { + return fmt.Errorf("initialise docker client: %w", err) + } + + composeService := composev2.NewComposeService(dockerCli) + buildOpts := composeapi.BuildOptions{ + Args: composetypes.NewMappingWithEquals(opts.BuildArgs), + Check: opts.Check, + Deps: opts.Deps, + NoCache: opts.NoCache, + Pull: opts.Pull, + Push: opts.PushRegistry, + Services: opts.Services, + } + + if err = composeService.Build(ctx, project, buildOpts); err != nil { + return err + } + + if !opts.PushCluster { + return nil + } + + // Push built service images to cluster machines. + builtServices, err := ServicesThatNeedBuild(project, opts.Services, opts.Deps) + if err != nil { + return fmt.Errorf("determine built services: %w", err) + } + if len(builtServices) == 0 { + // No services were built, nothing to push. + return nil + } + + // Add a line break after the build output. + fmt.Fprintln(cli.ProgressOut()) + + clusterClient, err := cli.ConnectCluster(ctx, opts.Context) + if err != nil { + return fmt.Errorf("connect to cluster: %w", err) + } + defer clusterClient.Close() + + // Push one service image at a time. + var errs []error + for _, s := range builtServices { + if s.Image == "" { + // Skip services without an image name (shouldn't happen for services with build config). + continue + } + + // Push to the specified machines falling back to service x-machines. + // If none specified, push to *all* cluster machines. + var pushOpts client.PushImageOptions + + if len(opts.Machines) > 0 { + pushOpts.Machines = opts.Machines + } else if machines, ok := s.Extensions[compose.MachinesExtensionKey].(compose.MachinesSource); ok { + pushOpts.Machines = machines + } + + if len(pushOpts.Machines) == 0 { + pushOpts.AllMachines = true + } + + boldStyle := lipgloss.NewStyle().Bold(true) + err = progress.RunWithTitle(ctx, func(ctx context.Context) error { + if err = clusterClient.PushImage(ctx, s.Image, pushOpts); err != nil { + return fmt.Errorf("push image '%s' for service '%s': %w", s.Image, s.Name, err) + } + return nil + }, cli.ProgressOut(), fmt.Sprintf("Pushing image %s to cluster", boldStyle.Render(s.Image))) + + // Collect errors to try pushing all images. + if err != nil { + errs = append(errs, err) + } + } + + return errors.Join(errs...) +} + +// ServicesThatNeedBuild returns a list 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)) +) ([]composetypes.ServiceConfig, error) { + servicesToBuild := make([]composetypes.ServiceConfig, 0, len(project.Services)) var policy composetypes.DependencyOption = composetypes.IgnoreDependencies if deps { @@ -55,7 +166,7 @@ func ServicesThatNeedBuild( err = project.ForEachService(selectedServices, func(serviceName string, service *composetypes.ServiceConfig) error { if service.Build != nil { - servicesToBuild[serviceName] = *service + servicesToBuild = append(servicesToBuild, *service) } return nil }, policy) @@ -63,12 +174,6 @@ func ServicesThatNeedBuild( return nil, err } - for serviceName, service := range project.Services { - if service.Build != nil { - servicesToBuild[serviceName] = service - } - } - return servicesToBuild, nil } @@ -95,140 +200,3 @@ func includeAdditionalContextsServices(project *composetypes.Project, selectedSe return servicesWithDependencies.ToSlice() } - -// BuildServices builds the services defined in the provided map. -func BuildServices(ctx context.Context, servicesToBuild map[string]composetypes.ServiceConfig, opts BuildOptions) error { - fmt.Println("Building services...") - - // Init docker client (can be local or remote, depending on DOCKER_HOST environment variable) - dockerCli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation()) - if err != nil { - return err - } - defer dockerCli.Close() - - serviceImages := make(map[string]string, len(servicesToBuild)) - - // Build the services using the local docker client and compose libraries - for _, service := range servicesToBuild { - fmt.Printf("Building service: %s\n", service.Name) - imageName, err := buildSingleService(ctx, dockerCli, service, opts) - if err != nil { - return fmt.Errorf("build service %s: %w", service.Name, err) - } - serviceImages[service.Name] = imageName - } - fmt.Printf("Service images are built.\n") - - if opts.Push { - err = pushServiceImages(ctx, dockerCli, serviceImages) - } - - return err -} - -// buildSingleService builds a single service using the Docker client and Compose libraries. -func buildSingleService(ctx context.Context, dockerCli *dockerclient.Client, service composetypes.ServiceConfig, opts BuildOptions) (string, error) { - if service.Build == nil { - return "", fmt.Errorf("service %s has no build configuration", service.Name) - } - if service.Image == "" { - return "", fmt.Errorf("service %s has no image specified; building services without image is not supported yet", - service.Name) - } - - buildContextPath := service.Build.Context - imageName := service.Image - - // Create a tar archive of the build context - buildContext, err := archive.TarWithOptions(buildContextPath, &archive.TarOptions{}) - if err != nil { - return "", fmt.Errorf("failed to create build context for service %s: %w", service.Name, err) - } - - buildOptions := build.ImageBuildOptions{ - // TODO: Support Dockerfiles outside the build context - // See https://github.com/docker/compose/blob/cf89fd1aa1328d5af77658ccc5a1e1b29981ae80/pkg/compose/build_classic.go#L92 - Dockerfile: service.Build.Dockerfile, - Tags: []string{imageName}, - Remove: true, // Remove intermediate containers - NoCache: opts.NoCache, - } - - buildResponse, err := dockerCli.ImageBuild(ctx, buildContext, buildOptions) - if err != nil { - return "", fmt.Errorf("failed to build image for service %s: %w", service.Name, err) - } - defer buildResponse.Body.Close() - - // Display the build response - fd, isTerminal := term.GetFdInfo(os.Stdout) - if err := jsonmessage.DisplayJSONMessagesStream(buildResponse.Body, os.Stdout, fd, isTerminal, nil); err != nil { - return "", fmt.Errorf("failed to display build response for service %s: %w", service.Name, err) - } - - return imageName, nil -} - -// pushSingleServiceImage pushes a single service image. -func pushSingleServiceImage(ctx context.Context, dockerCli *dockerclient.Client, serviceName string, imageName string) error { - ref, err := reference.ParseNormalizedNamed(imageName) - if err != nil { - return err - } - - repoInfo, err := registry.ParseRepositoryInfo(ref) - if err != nil { - return err - } - - registryKey := repoInfo.Index.Name - if repoInfo.Index.Official { - registryKey = registry.IndexServer - } - - // Load the Docker config file with auth details, if available - configFile := config.LoadDefaultConfigFile(os.Stderr) - - authConfig, err := configFile.GetAuthConfig(registryKey) - if err != nil { - return err - } - - authJSON, err := json.Marshal(authConfig) - if err != nil { - return fmt.Errorf("failed to marshal auth config for registry %s: %w", registryKey, err) - } - - authStr := base64.URLEncoding.EncodeToString(authJSON) - - pushOptions := image.PushOptions{ - RegistryAuth: authStr, - } - pushResponse, err := dockerCli.ImagePush(ctx, imageName, pushOptions) - if err != nil { - return fmt.Errorf("failed to push image %s: %w", imageName, err) - } - defer pushResponse.Close() - - fmt.Printf("Pushing image %s for service %s...\n", imageName, serviceName) - - fd, isTerminal := term.GetFdInfo(os.Stdout) - if err := jsonmessage.DisplayJSONMessagesStream(pushResponse, os.Stdout, fd, isTerminal, nil); err != nil { - return fmt.Errorf("failed to display push response for image %s: %w", imageName, err) - } - - fmt.Printf("Image %s pushed successfully.\n", imageName) - return nil -} - -// pushServiceImages pushes all built service images to the registry. -func pushServiceImages(ctx context.Context, dockerCli *dockerclient.Client, serviceImages map[string]string) error { - fmt.Printf("Pushing images...\n") - for serviceName, imageName := range serviceImages { - if err := pushSingleServiceImage(ctx, dockerCli, serviceName, imageName); err != nil { - return fmt.Errorf("push image for service %s: %w", serviceName, err) - } - } - return nil -} diff --git a/test/e2e/compose_build_test.go b/test/e2e/compose_build_test.go index 9e873f76..434802af 100644 --- a/test/e2e/compose_build_test.go +++ b/test/e2e/compose_build_test.go @@ -16,7 +16,6 @@ import ( "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/psviderski/uncloud/internal/cli" - cliInternal "github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/ucind" "github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/client/compose" @@ -98,7 +97,7 @@ func TestComposeBuild(t *testing.T) { }), ) require.NoError(t, err) - servicesToBuild, err := cliInternal.ServicesThatNeedBuild(project, nil, false) + servicesToBuild, err := cli.ServicesThatNeedBuild(project, nil, false) require.NoError(t, err) serviceImage1 := project.Services["service-first"].Image // contains auto-generated default tag serviceImage2 := fmt.Sprintf("127.0.0.1:%d/service-second:version2", registryHostPort) @@ -111,8 +110,8 @@ func TestComposeBuild(t *testing.T) { assert.NoErrorf(t, err, "failed to remove image %s on test cleanup", serviceImage2) }) - servicesToBuildExpected := map[string]types.ServiceConfig{ - "service-first": { + servicesToBuildExpected := []types.ServiceConfig{ + { Name: "service-first", Build: &types.BuildConfig{ Context: path.Join(project.WorkingDir, "service-first-dir"), @@ -124,7 +123,7 @@ func TestComposeBuild(t *testing.T) { "default": nil, }, }, - "service-second": { + { Name: "service-second", Build: &types.BuildConfig{ Context: path.Join(project.WorkingDir, "service-second-dir"), @@ -137,14 +136,16 @@ func TestComposeBuild(t *testing.T) { }, }, } - assert.Equal(t, servicesToBuildExpected, servicesToBuild) + assert.ElementsMatch(t, servicesToBuildExpected, servicesToBuild) - // Build and push the images - buildOpts := cli.BuildOptions{ - Push: true, - NoCache: false, + // Build and push the images to the test registry. + // Create a minimal CLI instance for the build function (it's only used for cluster push, which we're not using here). + uncli := &cli.CLI{} + buildOpts := cli.BuildServicesOptions{ + PushRegistry: true, } - cli.BuildServices(context.Background(), servicesToBuild, buildOpts) + err = uncli.BuildServices(context.Background(), project, buildOpts) + require.NoError(t, err) // Check the image of the first service tagSeparatorIdx := strings.LastIndex(serviceImage1, ":")