diff --git a/pkg/cmd/deploy.go b/pkg/cmd/deploy.go index 2803ec6..2b5809d 100644 --- a/pkg/cmd/deploy.go +++ b/pkg/cmd/deploy.go @@ -13,9 +13,10 @@ import ( ) var ( - artifact string - replicas int32 - dryRun bool + artifact string + replicas int32 + dryRun bool + customName string ) var deployCmd = &cobra.Command{ @@ -23,7 +24,7 @@ var deployCmd = &cobra.Command{ Short: "Deploy application to Kubernetes", Hidden: isExperimentalFlagNotSet, RunE: func(_ *cobra.Command, _ []string) error { - name, err := getNameFromImageReference(artifact) + name, err := getSpinAppName(artifact, customName) if err != nil { return err } @@ -65,7 +66,7 @@ func init() { deployCmd.Flags().BoolVar(&dryRun, "dry-run", false, "only print the kubernetes manifest without deploying") deployCmd.Flags().Int32VarP(&replicas, "replicas", "r", 2, "Number of replicas for the application") deployCmd.Flags().StringVarP(&artifact, "from", "f", "", "Reference in the registry of the application") - + deployCmd.Flags().StringVarP(&customName, "name", "", "", "Overwrite the generated name of the application") if err := deployCmd.MarkFlagRequired("from"); err != nil { log.Fatal(err) } diff --git a/pkg/cmd/scaffold.go b/pkg/cmd/scaffold.go index 68d3a5b..e10df1e 100644 --- a/pkg/cmd/scaffold.go +++ b/pkg/cmd/scaffold.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "os" + "regexp" "strings" "text/template" @@ -14,6 +15,7 @@ import ( ) type ScaffoldOptions struct { + name string autoscaler string configfile string cpuLimit string @@ -250,8 +252,7 @@ func scaffold(opts ScaffoldOptions) ([]byte, error) { if err := validateFlags(opts); err != nil { return nil, err } - - name, err := getNameFromImageReference(opts.from) + name, err := getSpinAppName(opts.from, opts.name) if err != nil { return nil, err } @@ -302,18 +303,45 @@ func validateImageReference(imageRef string) bool { return err == nil } -func getNameFromImageReference(imageRef string) (string, error) { - ref, err := dockerparser.Parse(imageRef) - if err != nil { - return "", err - } +func validateName(name string) bool { + // ensure name is a valid DNS subdomain + const pattern = `^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$` - if strings.Contains(ref.ShortName(), "/") { - parts := strings.Split(ref.ShortName(), "/") - return parts[len(parts)-1], nil + // check length + if len(name) < 1 || len(name) > 253 { + return false } - return ref.ShortName(), nil + // Compile the regex + re := regexp.MustCompile(pattern) + + // Match the name against the pattern + return re.MatchString(name) +} + +// / Retrieve the desired name for the SpinApp CR +// / If provided, custom name has highest priority +// / If not provided, the name is computed from the image reference +// / In either case, the resulting name will be validated using the `validateName` func +func getSpinAppName(imageRef string, customName string) (string, error) { + name := customName + if len(name) == 0 { + ref, err := dockerparser.Parse(imageRef) + if err != nil { + return "", err + } + switch strings.Contains(ref.ShortName(), "/") { + case true: + parts := strings.Split(ref.ShortName(), "/") + name = parts[len(parts)-1] + case false: + name = ref.ShortName() + } + } + if !validateName(name) { + return "", fmt.Errorf("invalid name provided. Must be a valid DNS subdomain name and not more than 253 chars") + } + return name, nil } func init() { @@ -333,7 +361,7 @@ func init() { scaffoldCmd.Flags().StringSliceVarP(&scaffoldOpts.imagePullSecrets, "image-pull-secret", "s", []string{}, "Secrets in the same namespace to use for pulling the image") scaffoldCmd.PersistentFlags().StringToStringVarP(&scaffoldOpts.variables, "variable", "v", nil, "Application variable (name=value) to be provided to the application") scaffoldCmd.PersistentFlags().StringSliceVarP(&scaffoldOpts.components, "component", "", nil, "Component ID to run. This can be specified multiple times. The default is all components.") - + scaffoldCmd.Flags().StringVarP(&scaffoldOpts.name, "name", "", "", "Overwrite the generated name of the application") if err := scaffoldCmd.MarkFlagRequired("from"); err != nil { log.Fatal(err) } diff --git a/pkg/cmd/scaffold_test.go b/pkg/cmd/scaffold_test.go index 0c75b01..0969f8d 100644 --- a/pkg/cmd/scaffold_test.go +++ b/pkg/cmd/scaffold_test.go @@ -3,6 +3,7 @@ package cmd import ( "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/require" @@ -111,6 +112,16 @@ func TestScaffoldOutput(t *testing.T) { }, expected: "components.yml", }, + { + name: "overwrite name", + opts: ScaffoldOptions{ + from: "ghcr.io/foo/example-app:v0.1.0", + replicas: 2, + executor: "containerd-shim-spin", + name: "my-custom-name", + }, + expected: "overwrite_name.yml", + }, } for _, tc := range testcases { @@ -147,42 +158,59 @@ func TestValidateImageReference_ValidImageReference(t *testing.T) { } } -func TestGetNameFromImageReference(t *testing.T) { +func TestGetSpinAppName(t *testing.T) { testCases := []struct { - reference string - name string + reference string + customName string + name string }{ { - reference: "bacongobbler/hello-rust", - name: "hello-rust", + reference: "bacongobbler/hello-rust", + customName: "", + name: "hello-rust", }, { - reference: "bacongobbler/hello-rust:v1.0.0", - name: "hello-rust", + reference: "bacongobbler/hello-rust:v1.0.0", + customName: "", + name: "hello-rust", }, { - reference: "ghcr.io/bacongobbler/hello-rust", - name: "hello-rust", + reference: "ghcr.io/bacongobbler/hello-rust", + customName: "", + name: "hello-rust", + }, { + reference: "ghcr.io/bacongobbler/hello-rust:v1.0.0", + customName: "", + name: "hello-rust", + }, { + reference: "ghcr.io/spinkube/spinkube/runtime-class-manager:v1", + customName: "", + name: "runtime-class-manager", }, { - reference: "ghcr.io/bacongobbler/hello-rust:v1.0.0", - name: "hello-rust", + reference: "nginx:latest", + customName: "", + name: "nginx", }, { - reference: "ghcr.io/spinkube/spinkube/runtime-class-manager:v1", - name: "runtime-class-manager", + reference: "nginx:latest", + customName: "web-server", + name: "web-server", }, { - reference: "nginx:latest", - name: "nginx", + reference: "nginx", + customName: "", + name: "nginx", }, { - reference: "nginx", - name: "nginx", + reference: "ttl.sh/hello-spinkube@sha256:cc4b191d11728b4e9e024308f0c03aded893da2002403943adc9deb8c4ca1644", + customName: "", + name: "hello-spinkube", }, { - reference: "ttl.sh/hello-spinkube@sha256:cc4b191d11728b4e9e024308f0c03aded893da2002403943adc9deb8c4ca1644", - name: "hello-spinkube", + reference: "ttl.sh/hello-spinkube@sha256:cc4b191d11728b4e9e024308f0c03aded893da2002403943adc9deb8c4ca1644", + customName: "hi-spinkube", + name: "hi-spinkube", }, } for _, tc := range testCases { t.Run(tc.reference, func(t *testing.T) { - actualName, err := getNameFromImageReference(tc.reference) + actualName, err := getSpinAppName(tc.reference, tc.customName) require.Nil(t, err) require.Equal(t, tc.name, actualName, "Expected image name from reference") }) @@ -305,6 +333,22 @@ func TestFlagValidation(t *testing.T) { }, expectedError: "target memory utilization percentage (0) must be between 1 and 100", }, + { + name: "must provide valid DNS subdomain name", + opts: ScaffoldOptions{ + from: "ghcr.io/foo/example-app:v0.1.0", + name: "my*app", + }, + expectedError: "invalid name provided. Must be a valid DNS subdomain name and not more than 253 chars", + }, + { + name: "must provide valid DNS subdomain name 2", + opts: ScaffoldOptions{ + from: "ghcr.io/foo/example-app:v0.1.0", + name: strings.Repeat("a", 254), + }, + expectedError: "invalid name provided. Must be a valid DNS subdomain name and not more than 253 chars", + }, } for _, tc := range testcases { diff --git a/pkg/cmd/testdata/overwrite_name.yml b/pkg/cmd/testdata/overwrite_name.yml new file mode 100644 index 0000000..597cd81 --- /dev/null +++ b/pkg/cmd/testdata/overwrite_name.yml @@ -0,0 +1,8 @@ +apiVersion: core.spinkube.dev/v1alpha1 +kind: SpinApp +metadata: + name: my-custom-name +spec: + image: "ghcr.io/foo/example-app:v0.1.0" + executor: containerd-shim-spin + replicas: 2