diff --git a/docs/user/gen-docs/kyma_app_push.md b/docs/user/gen-docs/kyma_app_push.md index 42b9e8f0f..fcb2eb018 100644 --- a/docs/user/gen-docs/kyma_app_push.md +++ b/docs/user/gen-docs/kyma_app_push.md @@ -17,6 +17,10 @@ kyma app push [flags] # The application will be built using Cloud Native Buildpacks: kyma app push --name my-app --code-path . + # Push with a custom image tag (e.g. a Git commit SHA for CI/CD traceability): + kyma app push --name my-app --code-path . --build-tag abc1234 + kyma app push --name my-app --dockerfile ./Dockerfile --build-tag $GITHUB_SHA + # Push an application based on a Dockerfile located in the current directory: kyma app push --name my-app --dockerfile ./Dockerfile --dockerfile-context . @@ -65,6 +69,7 @@ kyma app push [flags] ## Flags ```text + --build-tag string Custom tag for the built image (e.g. a Git commit SHA). Applies only to --code-path and --dockerfile builds. --code-path string Path to the application source code directory --container-port int Port on which the application is exposed --dockerfile string Path to the Dockerfile diff --git a/internal/cmd/app/push.go b/internal/cmd/app/push.go index bf0d0fa35..3b96bbffa 100644 --- a/internal/cmd/app/push.go +++ b/internal/cmd/app/push.go @@ -3,6 +3,7 @@ package app import ( "fmt" "os" + "regexp" "time" "github.com/kyma-project/cli.v3/internal/clierror" @@ -20,6 +21,8 @@ import ( "github.com/spf13/cobra" ) +var buildTagRegexp = regexp.MustCompile(`^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$`) + type appPushConfig struct { *cmdcommon.KymaConfig @@ -27,6 +30,7 @@ type appPushConfig struct { namespace string image string imagePullSecretName string + buildTag string dockerfilePath string dockerfileSrcContext string dockerfileArgs types.Map @@ -59,6 +63,10 @@ func NewAppPushCMD(kymaConfig *cmdcommon.KymaConfig) *cobra.Command { # The application will be built using Cloud Native Buildpacks: kyma app push --name my-app --code-path . + # Push with a custom image tag (e.g. a Git commit SHA for CI/CD traceability): + kyma app push --name my-app --code-path . --build-tag abc1234 + kyma app push --name my-app --dockerfile ./Dockerfile --build-tag $GITHUB_SHA + # Push an application based on a Dockerfile located in the current directory: kyma app push --name my-app --dockerfile ./Dockerfile --dockerfile-context . @@ -110,6 +118,7 @@ func NewAppPushCMD(kymaConfig *cmdcommon.KymaConfig) *cobra.Command { flags.MarkExactlyOneRequired("image", "dockerfile", "code-path"), flags.MarkExclusive("dockerfile-context", "image", "code-path"), flags.MarkExclusive("dockerfile-build-arg", "image", "code-path"), + flags.MarkExclusive("build-tag", "image"), flags.MarkPrerequisites("expose", "container-port"), flags.MarkPrerequisites("image-pull-secret", "image"), )) @@ -132,6 +141,7 @@ func NewAppPushCMD(kymaConfig *cmdcommon.KymaConfig) *cobra.Command { // image flags cmd.Flags().StringVar(&config.image, "image", "", "Name of the image to deploy") cmd.Flags().StringVar(&config.imagePullSecretName, "image-pull-secret", "", "Name of the Kubernetes Secret with credentials to pull the image") + cmd.Flags().StringVar(&config.buildTag, "build-tag", "", "Custom tag for the built image (e.g. a Git commit SHA). Applies only to --code-path and --dockerfile builds.") // dockerfile flags cmd.Flags().StringVar(&config.dockerfilePath, "dockerfile", "", "Path to the Dockerfile") @@ -178,6 +188,17 @@ func (apc *appPushConfig) complete() clierror.Error { } } + if apc.buildTag != "" { + if !buildTagRegexp.MatchString(apc.buildTag) { + return clierror.New( + fmt.Sprintf("invalid image tag %q", apc.buildTag), + "tag must start with a letter, digit, or underscore", + "tag may only contain letters, digits, underscores, dots, and hyphens", + "tag must be at most 128 characters", + ) + } + } + return nil } @@ -317,7 +338,7 @@ func buildAndImportImage(client kube.Client, cfg *appPushConfig, registryConfig out.Msgln("Building image\n") imageName, err := buildImage(cfg) if err != nil { - return "", clierror.Wrap(err, clierror.New("failed to build image from Dockerfile")) + return "", clierror.Wrap(err, clierror.New("failed to build image")) } pushFunc := registry.NewPushWithPortforwardFunc( @@ -352,9 +373,16 @@ func buildAndImportImage(client kube.Client, cfg *appPushConfig, registryConfig return pushedImage, nil } +// resolveImageTag returns imageTag if non-empty, otherwise a timestamp-based tag. +func resolveImageTag(imageTag string) string { + if imageTag != "" { + return imageTag + } + return time.Now().Format("2006-01-02_15-04-05") +} + func buildImage(cfg *appPushConfig) (string, error) { - imageTag := time.Now().Format("2006-01-02_15-04-05") - imageName := fmt.Sprintf("%s:%s", cfg.name, imageTag) + imageName := fmt.Sprintf("%s:%s", cfg.name, resolveImageTag(cfg.buildTag)) var err error if cfg.packAppPath != "" { diff --git a/internal/cmd/app/push_test.go b/internal/cmd/app/push_test.go new file mode 100644 index 000000000..4946b7697 --- /dev/null +++ b/internal/cmd/app/push_test.go @@ -0,0 +1,108 @@ +package app + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_appPushConfig_complete_buildTag(t *testing.T) { + tests := []struct { + name string + buildTag string + wantErr bool + }{ + { + name: "empty tag skips validation - no error", + buildTag: "", + wantErr: false, + }, + { + name: "valid commit sha", + buildTag: "abc1234def5678", + wantErr: false, + }, + { + name: "valid semver", + buildTag: "1.0.0", + wantErr: false, + }, + { + name: "valid underscore prefix", + buildTag: "_build", + wantErr: false, + }, + { + name: "valid single char", + buildTag: "1", + wantErr: false, + }, + { + name: "valid with dots and dashes", + buildTag: "v1.2.3-beta.1", + wantErr: false, + }, + { + name: "invalid: contains space", + buildTag: "my tag", + wantErr: true, + }, + { + name: "invalid: contains colon", + buildTag: "my:tag", + wantErr: true, + }, + { + name: "invalid: contains slash", + buildTag: "my/tag", + wantErr: true, + }, + { + name: "invalid: contains @", + buildTag: "my@tag", + wantErr: true, + }, + { + name: "invalid: starts with dot", + buildTag: ".mytag", + wantErr: true, + }, + { + name: "invalid: starts with dash", + buildTag: "-mytag", + wantErr: true, + }, + { + name: "invalid: exceeds 128 chars", + buildTag: "abbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &appPushConfig{ + buildTag: tt.buildTag, + } + err := cfg.complete() + if tt.wantErr { + require.NotNil(t, err, "expected validation error for buildTag=%q", tt.buildTag) + } else { + require.Nil(t, err, "expected no error for buildTag=%q", tt.buildTag) + } + }) + } +} + +func Test_resolveImageTag(t *testing.T) { + t.Run("provided tag is used in image name", func(t *testing.T) { + imageTag := "abc1234" + resolvedTag := resolveImageTag(imageTag) + require.Equal(t, "abc1234", resolvedTag) + }) + + t.Run("empty tag resolves to timestamp format", func(t *testing.T) { + resolvedTag := resolveImageTag("") + require.Regexp(t, `^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$`, resolvedTag) + }) +}