mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 11:03:34 +00:00
feat(build,deploy): build service images using compose (bake/buildkit), push to cluster, and deploy
This commit is contained in:
+66
-34
@@ -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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
+57
-25
@@ -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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -80,7 +80,6 @@ func main() {
|
||||
NewDeployCommand(),
|
||||
NewDocsCommand(),
|
||||
NewBuildCommand(),
|
||||
NewCBuildCommand(),
|
||||
NewImagesCommand(),
|
||||
caddy.NewRootCommand(),
|
||||
cmdcontext.NewRootCommand(),
|
||||
|
||||
+132
-164
@@ -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
|
||||
Services []string
|
||||
Push bool
|
||||
// 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 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
|
||||
}
|
||||
|
||||
@@ -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, ":")
|
||||
|
||||
Reference in New Issue
Block a user