diff --git a/cmd/uncloud/caddy/deploy.go b/cmd/uncloud/caddy/deploy.go index dd99472c..f7886fcc 100644 --- a/cmd/uncloud/caddy/deploy.go +++ b/cmd/uncloud/caddy/deploy.go @@ -44,8 +44,8 @@ func NewDeployCommand() *cobra.Command { cmd.Flags().StringVar(&opts.image, "image", "", "Caddy Docker image to deploy. (default caddy:LATEST_VERSION)") cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil, - "Machine names to deploy to. Can be specified multiple times or as a comma-separated "+ - "list of machine names. (default is all machines)") + "Machine names or IDs to deploy to. Can be specified multiple times or as a comma-separated "+ + "list. (default is all machines)") cmd.Flags().StringVarP( &opts.context, "context", "c", "", "Name of the cluster context to deploy to. (default is the current context)", diff --git a/cmd/uncloud/cbuild.go b/cmd/uncloud/cbuild.go index da4a768c..a4aa3b9f 100644 --- a/cmd/uncloud/cbuild.go +++ b/cmd/uncloud/cbuild.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" composecli "github.com/compose-spec/compose-go/v2/cli" @@ -10,20 +11,26 @@ import ( "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 - noCache bool - profiles []string - pull bool - services []string + 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. @@ -32,7 +39,7 @@ func NewCBuildCommand() *cobra.Command { cmd := &cobra.Command{ Use: "cbuild [FLAGS] [SERVICE...]", Short: "Build services from a Compose file.", - Long: "Build images for services from a Compose file using the Docker Compose library.", + 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) @@ -54,12 +61,24 @@ func NewCBuildCommand() *cobra.Command { "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 } @@ -76,7 +95,12 @@ func projectOptsFromCBuildOpts(opts buildOptions) []composecli.ProjectOptionsFn } // runCBuild parses the Compose file(s) and builds the images for selected services. -func runCBuild(ctx context.Context, _ *cli.CLI, opts buildOptions) error { +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 { @@ -95,12 +119,12 @@ func runCBuild(ctx context.Context, _ *cli.CLI, opts buildOptions) error { // Build service images using Compose implementation. dockerCli, err := command.NewDockerCli() if err != nil { - return fmt.Errorf("create docker CLI: %w", err) + 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("initialize docker CLI: %w", err) + return fmt.Errorf("initialise docker client: %w", err) } composeService := composev2.NewComposeService(dockerCli) @@ -110,6 +134,7 @@ func runCBuild(ctx context.Context, _ *cli.CLI, opts buildOptions) error { Deps: opts.deps, NoCache: opts.noCache, Pull: opts.pull, + Push: opts.pushRegistry, Services: opts.services, } @@ -117,5 +142,62 @@ func runCBuild(ctx context.Context, _ *cli.CLI, opts buildOptions) error { 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/image/push.go b/cmd/uncloud/image/push.go index 0a09d6e2..9446677f 100644 --- a/cmd/uncloud/image/push.go +++ b/cmd/uncloud/image/push.go @@ -14,8 +14,8 @@ import ( type pushOptions struct { image string machines []string - context string platform string + context string } func NewPushCommand() *cobra.Command { @@ -50,9 +50,8 @@ The image is uploaded to the machine which CLI is connected to (default) or the } cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil, - "Machine names to push the image to. Can be specified multiple times or as a comma-separated "+ - "list of machine names.\n"+ - "Use 'all' to push to all machines. (default is connected machine)") + "Machine names or IDs to push the image to. Can be specified multiple times or as a comma-separated list. "+ + "(default is all machines)") cmd.Flags().StringVar( &opts.platform, "platform", "", "Push a specific platform of a multi-platform image (e.g., linux/amd64, linux/arm64).\n"+ @@ -76,11 +75,14 @@ func push(ctx context.Context, uncli *cli.CLI, opts pushOptions) error { machines := cli.ExpandCommaSeparatedValues(opts.machines) pushOpts := client.PushImageOptions{} - // Special handling for "all" keyword to push to all machines. + // Special handling for an explicit "all" keyword to push to all machines. if len(machines) == 1 && machines[0] == "all" { pushOpts.AllMachines = true - } else { + } else if len(machines) > 0 { pushOpts.Machines = machines + } else { + // Default is to push to all machines in the cluster. + pushOpts.AllMachines = true } if opts.platform != "" { diff --git a/pkg/client/image.go b/pkg/client/image.go index f1a5f563..ecf617a6 100644 --- a/pkg/client/image.go +++ b/pkg/client/image.go @@ -102,11 +102,11 @@ func (cli *Client) ListImages(ctx context.Context, filter api.ImageFilter) ([]ap } type PushImageOptions struct { + // AllMachines pushes the image to all machines in the cluster. Takes precedence over Machines field. + AllMachines bool // Machines is a list of machine names or IDs to push the image to. If empty and AllMachines is false, // pushes to the machine the client is connected to. Machines []string - // AllMachines pushes the image to all machines in the cluster. Takes precedence over Machines field. - AllMachines bool // Platform to push for a multi-platform image. Local Docker must use containerd image store // to support multi-platform images. Platform *ocispec.Platform