feat(build,deploy): build service images using compose (bake/buildkit), push to cluster, and deploy

This commit is contained in:
Pasha Sviderski
2025-11-04 18:14:42 +10:00
parent dde23b5549
commit 882f2f5d03
6 changed files with 270 additions and 441 deletions
+68 -36
View File
@@ -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)
}
-203
View File
@@ -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...)
}
+58 -26
View File
@@ -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)
}
-1
View File
@@ -80,7 +80,6 @@ func main() {
NewDeployCommand(),
NewDocsCommand(),
NewBuildCommand(),
NewCBuildCommand(),
NewImagesCommand(),
caddy.NewRootCommand(),
cmdcontext.NewRootCommand(),