diff --git a/cmd/build.go b/cmd/build.go index ea58fe7bc..d4ef53bc8 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -54,6 +54,13 @@ func NewBuildCmd(flags *flags.GlobalFlags) *cobra.Command { } } + // validate tags + if len(cmd.Tag) > 0 { + if err := image.ValidateTags(cmd.Tag); err != nil { + return fmt.Errorf("cannot build image, %w", err) + } + } + // create a temporary workspace exists := workspace2.Exists(ctx, devPodConfig, args, "", log.Default) sshConfigFile, err := os.CreateTemp("", "devpodssh.config") @@ -112,6 +119,7 @@ func NewBuildCmd(flags *flags.GlobalFlags) *cobra.Command { buildCmd.Flags().BoolVar(&cmd.SkipDelete, "skip-delete", false, "If true will not delete the workspace after building it") buildCmd.Flags().StringVar(&cmd.Machine, "machine", "", "The machine to use for this workspace. The machine needs to exist beforehand or the command will fail. If the workspace already exists, this option has no effect") buildCmd.Flags().StringVar(&cmd.Repository, "repository", "", "The repository to push to") + buildCmd.Flags().StringSliceVar(&cmd.Tag, "tag", []string{}, "Image Tag(s) in the form of a comma separated list --tag latest,arm64 or multiple flags --tag latest --tag arm64") buildCmd.Flags().StringSliceVar(&cmd.Platform, "platform", []string{}, "Set target platform for build") buildCmd.Flags().BoolVar(&cmd.SkipPush, "skip-push", false, "If true will not push the image to the repository, useful for testing") buildCmd.Flags().Var(&cmd.GitCloneStrategy, "git-clone-strategy", "The git clone strategy DevPod uses to checkout git based workspaces. Can be full (default), blobless, treeless or shallow") diff --git a/pkg/devcontainer/build.go b/pkg/devcontainer/build.go index ff2525a58..5abd97850 100644 --- a/pkg/devcontainer/build.go +++ b/pkg/devcontainer/build.go @@ -104,6 +104,7 @@ func (r *runner) build( ImageName: overrideBuildImageName, PrebuildHash: imageTag, RegistryCache: options.RegistryCache, + Tags: options.Tag, }, nil } @@ -135,6 +136,7 @@ func (r *runner) extendImage( ImageMetadata: extendedBuildInfo.MetadataConfig, ImageName: imageBase, RegistryCache: options.RegistryCache, + Tags: options.Tag, }, nil } @@ -322,6 +324,7 @@ func (r *runner) buildImage( ImageName: prebuildImage, PrebuildHash: prebuildHash, RegistryCache: options.RegistryCache, + Tags: options.Tag, }, nil } else if err != nil { r.Log.Debugf("Error trying to find prebuild image %s: %v", prebuildImage, err) @@ -385,6 +388,7 @@ func dockerlessFallback( User: buildInfo.User, }, RegistryCache: options.RegistryCache, + Tags: options.Tag, }, nil } diff --git a/pkg/devcontainer/config/build.go b/pkg/devcontainer/config/build.go index 89a7521ca..fc1f954e9 100644 --- a/pkg/devcontainer/config/build.go +++ b/pkg/devcontainer/config/build.go @@ -22,6 +22,7 @@ type BuildInfo struct { ImageName string PrebuildHash string RegistryCache string + Tags []string Dockerless *BuildInfoDockerless } diff --git a/pkg/devcontainer/prebuild.go b/pkg/devcontainer/prebuild.go index e6c5eb352..ce7369e78 100644 --- a/pkg/devcontainer/prebuild.go +++ b/pkg/devcontainer/prebuild.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/loft-sh/devpod/pkg/devcontainer/config" "github.com/loft-sh/devpod/pkg/driver" @@ -70,16 +71,13 @@ func (r *runner) Build(ctx context.Context, options provider.BuildOptions) (stri } if isDockerComposeConfig(substitutedConfig.Config) { - r.Log.Debug("Tagging image prebuild=%s buildInfo=%s", prebuildImage, buildInfo.ImageName) - err = dockerDriver.TagDevContainer(ctx, buildInfo.ImageName, prebuildImage) - if err != nil { + if err := dockerDriver.TagDevContainer(ctx, buildInfo.ImageName, prebuildImage); err != nil { return "", errors.Wrap(err, "tag image") } } // check if we can push image - err = image.CheckPushPermissions(prebuildImage) - if err != nil { + if err := image.CheckPushPermissions(prebuildImage); err != nil { return "", fmt.Errorf( "cannot push to repository %s. Please make sure you are logged into the registry and credentials are available. (Error: %w)", prebuildImage, @@ -87,10 +85,28 @@ func (r *runner) Build(ctx context.Context, options provider.BuildOptions) (stri ) } + // Setup all image tags (prebuild and any user defined tags) + imageRefs := []string{prebuildImage} + + imageRepoName := strings.Split(prebuildImage, ":") + if buildInfo.Tags != nil { + for _, tag := range buildInfo.Tags { + imageRefs = append(imageRefs, imageRepoName[0]+":"+tag) + } + } + + // tag the image + for _, imageRef := range imageRefs { + if err := dockerDriver.TagDevContainer(ctx, prebuildImage, imageRef); err != nil { + return "", errors.Wrap(err, "tag image") + } + } + // push the image to the registry - err = dockerDriver.PushDevContainer(ctx, prebuildImage) - if err != nil { - return "", errors.Wrap(err, "push image") + for _, imageRef := range imageRefs { + if err := dockerDriver.PushDevContainer(ctx, imageRef); err != nil { + return "", errors.Wrap(err, "push image") + } } return prebuildImage, nil diff --git a/pkg/driver/docker/build.go b/pkg/driver/docker/build.go index c17388aef..f224dc8cc 100644 --- a/pkg/driver/docker/build.go +++ b/pkg/driver/docker/build.go @@ -46,6 +46,7 @@ func (d *dockerDriver) BuildDevContainer( ImageName: imageName, PrebuildHash: prebuildHash, RegistryCache: options.RegistryCache, + Tags: options.Tag, }, nil } else if err != nil { d.Log.Debugf("Error trying to find local image %s: %v", imageName, err) @@ -114,6 +115,7 @@ func (d *dockerDriver) BuildDevContainer( ImageName: imageName, PrebuildHash: prebuildHash, RegistryCache: options.RegistryCache, + Tags: options.Tag, }, nil } diff --git a/pkg/driver/docker/docker.go b/pkg/driver/docker/docker.go index 416e130ce..c9b00db27 100644 --- a/pkg/driver/docker/docker.go +++ b/pkg/driver/docker/docker.go @@ -112,7 +112,7 @@ func (d *dockerDriver) PushDevContainer(ctx context.Context, image string) error } func (d *dockerDriver) TagDevContainer(ctx context.Context, image, tag string) error { - // push image + // Tag image writer := d.Log.Writer(logrus.InfoLevel, false) defer writer.Close() @@ -127,7 +127,7 @@ func (d *dockerDriver) TagDevContainer(ctx context.Context, image, tag string) e d.Log.Debugf("Running docker command: %s %s", d.Docker.DockerCommand, strings.Join(args, " ")) err := d.Docker.Run(ctx, args, nil, writer, writer) if err != nil { - return errors.Wrap(err, "push image") + return errors.Wrap(err, "tag image") } return nil diff --git a/pkg/image/image.go b/pkg/image/image.go index f5ef097cc..8bd70a5b3 100644 --- a/pkg/image/image.go +++ b/pkg/image/image.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "regexp" "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" @@ -12,6 +13,11 @@ import ( "github.com/pkg/errors" ) +var ( + dockerTagRegexp = regexp.MustCompile(`^[\w][\w.-]*$`) + DockerTagMaxSize = 128 +) + func GetImage(ctx context.Context, image string) (v1.Image, error) { ref, err := name.ParseReference(image) if err != nil { @@ -58,3 +64,20 @@ func GetImageConfig(ctx context.Context, image string) (*v1.ConfigFile, v1.Image return configFile, img, nil } + +func ValidateTags(tags []string) error { + for _, tag := range tags { + if !IsValidDockerTag(tag) { + return fmt.Errorf(`%q is not a valid docker tag`, tag) + } + } + return nil +} + +func IsValidDockerTag(tag string) bool { + return shouldNotBeSlugged(tag, dockerTagRegexp, DockerTagMaxSize) +} + +func shouldNotBeSlugged(data string, regexp *regexp.Regexp, maxSize int) bool { + return len(data) == 0 || regexp.Match([]byte(data)) && len(data) <= maxSize +} diff --git a/pkg/provider/workspace.go b/pkg/provider/workspace.go index 81a356311..fe681db12 100644 --- a/pkg/provider/workspace.go +++ b/pkg/provider/workspace.go @@ -222,6 +222,7 @@ type CLIOptions struct { Repository string `json:"repository,omitempty"` SkipPush bool `json:"skipPush,omitempty"` Platform []string `json:"platform,omitempty"` + Tag []string `json:"tag,omitempty"` ForceBuild bool `json:"forceBuild,omitempty"` ForceDockerless bool `json:"forceDockerless,omitempty"`