diff --git a/packages/kn-plugin-workflow/pkg/command/deploy_undeploy_common.go b/packages/kn-plugin-workflow/pkg/command/deploy_undeploy_common.go index 6d8bd8d2a5b..bd9c3d7305c 100644 --- a/packages/kn-plugin-workflow/pkg/command/deploy_undeploy_common.go +++ b/packages/kn-plugin-workflow/pkg/command/deploy_undeploy_common.go @@ -32,26 +32,27 @@ import ( ) type DeployUndeployCmdConfig struct { - EmptyNameSpace bool - NameSpace string - KubectlContext string - SonataFlowFile string - CustomGeneratedManifestDir string - TempDir string - ApplicationPropertiesPath string - SubflowsDir string - SpecsDir string - SchemasDir string - CustomManifestsFileDir string - DefaultDashboardsFolder string - Profile string - Image string - SchemasFilesPath []string - SpecsFilesPath map[string]string - SubFlowsFilesPath []string - DashboardsPath []string - Minify bool - Wait bool + EmptyNameSpace bool + NameSpace string + KubectlContext string + SonataFlowFile string + CustomGeneratedManifestDir string + TempDir string + ApplicationPropertiesPath string + ApplicationSecretPropertiesPath string + SubflowsDir string + SpecsDir string + SchemasDir string + CustomManifestsFileDir string + DefaultDashboardsFolder string + Profile string + Image string + SchemasFilesPath []string + SpecsFilesPath map[string]string + SubFlowsFilesPath []string + DashboardsPath []string + Minify bool + Wait bool } func checkEnvironment(cfg *DeployUndeployCmdConfig) error { @@ -119,6 +120,12 @@ func generateManifests(cfg *DeployUndeployCmdConfig) error { fmt.Printf(" - ✅ Properties file found: %s\n", cfg.ApplicationPropertiesPath) } + applicationSecretPropertiesPath := findApplicationSecretPropertiesPath(dir) + if applicationSecretPropertiesPath != "" { + cfg.ApplicationSecretPropertiesPath = applicationSecretPropertiesPath + fmt.Printf(" - ✅ Secret Properties file found: %s\n", cfg.ApplicationSecretPropertiesPath) + } + supportFileExtensions := []string{metadata.JSONExtension, metadata.YAMLExtension, metadata.YMLExtension} fmt.Println("🔍 Looking for specs files...") @@ -182,6 +189,14 @@ func generateManifests(cfg *DeployUndeployCmdConfig) error { handler.WithAppProperties(appIO) } + if cfg.ApplicationSecretPropertiesPath != "" { + appIO, err := common.MustGetFile(cfg.ApplicationSecretPropertiesPath) + if err != nil { + return err + } + handler.WithSecretProperties(appIO) + } + for _, subflow := range cfg.SubFlowsFilesPath { specIO, err := common.MustGetFile(subflow) if err != nil { @@ -246,6 +261,17 @@ func findApplicationPropertiesPath(directoryPath string) string { return filePath } +func findApplicationSecretPropertiesPath(directoryPath string) string { + filePath := filepath.Join(directoryPath, metadata.ApplicationSecretProperties) + + fileInfo, err := os.Stat(filePath) + if err != nil || fileInfo.IsDir() { + return "" + } + + return filePath +} + func setupConfigManifestPath(cfg *DeployUndeployCmdConfig) error { if len(cfg.CustomGeneratedManifestDir) == 0 { diff --git a/packages/kn-plugin-workflow/pkg/metadata/constants.go b/packages/kn-plugin-workflow/pkg/metadata/constants.go index 9cd7a348bd7..37d9284e796 100644 --- a/packages/kn-plugin-workflow/pkg/metadata/constants.go +++ b/packages/kn-plugin-workflow/pkg/metadata/constants.go @@ -83,7 +83,7 @@ const ( YMLSWExtension = "sw.yml" JSONSWExtension = "sw.json" ApplicationProperties = "application.properties" - + ApplicationSecretProperties = "secret.properties" ManifestServiceFilesKind = "SonataFlow" DockerInternalPort = "8080/tcp" diff --git a/packages/sonataflow-operator/workflowproj/go.mod b/packages/sonataflow-operator/workflowproj/go.mod index ab3c3d06d98..4470e692ece 100644 --- a/packages/sonataflow-operator/workflowproj/go.mod +++ b/packages/sonataflow-operator/workflowproj/go.mod @@ -7,6 +7,7 @@ replace github.com/apache/incubator-kie-tools/packages/sonataflow-operator/api = require ( github.com/apache/incubator-kie-tools/packages/sonataflow-operator/api v0.0.0 + github.com/magiconair/properties v1.8.7 github.com/pb33f/libopenapi v0.8.4 github.com/pkg/errors v0.9.1 github.com/santhosh-tekuri/jsonschema/v5 v5.3.0 diff --git a/packages/sonataflow-operator/workflowproj/go.sum b/packages/sonataflow-operator/workflowproj/go.sum index 0ecd382c898..4c5684b5f03 100644 --- a/packages/sonataflow-operator/workflowproj/go.sum +++ b/packages/sonataflow-operator/workflowproj/go.sum @@ -86,6 +86,7 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= diff --git a/packages/sonataflow-operator/workflowproj/operator.go b/packages/sonataflow-operator/workflowproj/operator.go index f2290492570..458ec4c2ea1 100644 --- a/packages/sonataflow-operator/workflowproj/operator.go +++ b/packages/sonataflow-operator/workflowproj/operator.go @@ -31,9 +31,12 @@ import ( ) const ( - workflowUserConfigMapNameSuffix = "-props" + workflowUserConfigMapNameSuffix = "-props" + workflowUserSecretConfigMapNameSuffix = "-secrets" // ApplicationPropertiesFileName is the default application properties file name holding user properties - ApplicationPropertiesFileName = "application.properties" + ApplicationPropertiesFileName = "application.properties" + // SecretPropertiesFileName is the default application secret properties file name holding user secret properties + SecretPropertiesFileName = "secret.properties" workflowManagedConfigMapNameSuffix = "-managed-props" // LabelApp key to use among object selectors, "app" is used among k8s applications to group objects in some UI consoles LabelApp = "app" @@ -90,6 +93,11 @@ func GetManagedPropertiesFileName(workflow *operatorapi.SonataFlow) string { return fmt.Sprintf("application-%s.properties", profile) } +// GetWorkflowUserSecretPropertiesConfigMapName gets the default ConfigMap name that holds the user application secrets property for the given workflow +func GetWorkflowUserSecretPropertiesConfigMapName(workflow *operatorapi.SonataFlow) string { + return workflow.Name + workflowUserSecretConfigMapNameSuffix +} + // GetDefaultLabels gets the default labels based on the given workflow. func GetDefaultLabels(workflow *operatorapi.SonataFlow) map[string]string { labels := map[string]string{ @@ -143,6 +151,17 @@ func CreateNewUserPropsConfigMap(workflow *operatorapi.SonataFlow) *corev1.Confi } } +func CreateNewSecretPropsConfigMap(workflow *operatorapi.SonataFlow) *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: GetWorkflowUserSecretPropertiesConfigMapName(workflow), + Namespace: workflow.Namespace, + Labels: GetMergedLabels(workflow), + }, + } + +} + // CreateNewManagedPropsConfigMap creates a new ConfigMap object to hold the managed application properties of the workflows. func CreateNewManagedPropsConfigMap(workflow *operatorapi.SonataFlow, properties string) *corev1.ConfigMap { return &corev1.ConfigMap{ diff --git a/packages/sonataflow-operator/workflowproj/testdata/workflows/secret.properties b/packages/sonataflow-operator/workflowproj/testdata/workflows/secret.properties new file mode 100644 index 00000000000..5b302b06e5b --- /dev/null +++ b/packages/sonataflow-operator/workflowproj/testdata/workflows/secret.properties @@ -0,0 +1,25 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +MY_USER_PASSWORD=Super$ecretP@ssw0rd! +DATABASE_URL=https://secure.example.com +API_KEY=12345-ABCDE-67890-FGHIJ +ANOTHER_KEY=ThisIs@Complex#Value123 +DB_ROOT_PASSWORD=!P@$$w0rd_123# +JWT_SECRET=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 +ENCRYPTION_KEY=5up3r-53cr37-K3y! +KEY_WITH_NUMBERS_123=ValueWith123Numbers +SIMPLE_KEY=Simple_Value_2024 diff --git a/packages/sonataflow-operator/workflowproj/workflowproj.go b/packages/sonataflow-operator/workflowproj/workflowproj.go index 09b46b9ad71..7931516d7a9 100644 --- a/packages/sonataflow-operator/workflowproj/workflowproj.go +++ b/packages/sonataflow-operator/workflowproj/workflowproj.go @@ -23,9 +23,12 @@ import ( "context" "fmt" "io" + "regexp" "sort" "strings" + "github.com/magiconair/properties" + "github.com/pkg/errors" "github.com/serverlessworkflow/sdk-go/v2/model" "github.com/serverlessworkflow/sdk-go/v2/parser" @@ -56,6 +59,8 @@ type WorkflowProjectHandler interface { WithWorkflow(reader io.Reader) WorkflowProjectHandler // WithAppProperties reader for a file or the content stream of a workflow application properties. WithAppProperties(reader io.Reader) WorkflowProjectHandler + // WithSecretProperties reader for a file or the content stream of a workflow application properties. + WithSecretProperties(reader io.Reader) WorkflowProjectHandler // AddResource reader for a file or the content stream of any resource needed by the workflow. E.g. an OpenAPI specification file. // Name is required, should match the workflow function definition. AddResource(name string, reader io.Reader) WorkflowProjectHandler @@ -75,6 +80,8 @@ type WorkflowProject struct { Workflow *operatorapi.SonataFlow // Properties the application properties for the workflow Properties *corev1.ConfigMap + //SecretProperties the secret properties for the workflow + SecretProperties *corev1.Secret // Resources any resource that this workflow requires, like an OpenAPI specification file. Resources []*corev1.ConfigMap } @@ -98,15 +105,16 @@ func New(namespace string) WorkflowProjectHandler { } type workflowProjectHandler struct { - name string - namespace string - profile metadata.ProfileType - scheme *runtime.Scheme - project WorkflowProject - rawWorkflow io.Reader - rawAppProperties io.Reader - rawResources map[string][]*resource - parsed bool + name string + namespace string + profile metadata.ProfileType + scheme *runtime.Scheme + project WorkflowProject + rawWorkflow io.Reader + rawAppProperties io.Reader + rawSecretProperties io.Reader + rawResources map[string][]*resource + parsed bool } func (w *workflowProjectHandler) Named(name string) WorkflowProjectHandler { @@ -133,6 +141,12 @@ func (w *workflowProjectHandler) WithAppProperties(reader io.Reader) WorkflowPro return w } +func (w *workflowProjectHandler) WithSecretProperties(reader io.Reader) WorkflowProjectHandler { + w.rawSecretProperties = reader + w.parsed = false + return w +} + func (w *workflowProjectHandler) AddResource(name string, reader io.Reader) WorkflowProjectHandler { return w.AddResourceAt(name, defaultResourcePath, reader) } @@ -163,6 +177,12 @@ func (w *workflowProjectHandler) SaveAsKubernetesManifests(path string) error { return err } } + if w.project.SecretProperties != nil { + fileCount++ + if err := saveAsKubernetesManifest(w.project.SecretProperties, path, fileCount); err != nil { + return err + } + } for _, r := range w.project.Resources { fileCount++ if err := saveAsKubernetesManifest(r, path, fileCount); err != nil { @@ -196,6 +216,9 @@ func (w *workflowProjectHandler) parseRawProject() error { if err := w.parseRawAppProperties(); err != nil { return err } + if err := w.parseRawSecretProperties(); err != nil { + return err + } if err := w.parseRawResources(); err != nil { return err } @@ -261,6 +284,52 @@ func (w *workflowProjectHandler) parseRawAppProperties() error { return nil } +func (w *workflowProjectHandler) parseRawSecretProperties() error { + if w.rawSecretProperties == nil { + return nil + } + secretPropsContent, err := io.ReadAll(w.rawSecretProperties) + if err != nil { + return fmt.Errorf("Failed to read secret properties: %w", err) + } + secrets, err := properties.Load(secretPropsContent, properties.UTF8) + if err != nil { + return fmt.Errorf("Failed to load secret properties: %w", err) + } + if len(secrets.Map()) == 0 { + return nil //Do not create a secret if there are no secrets + } + + w.project.SecretProperties = CreateNewSecretPropsConfigMap(w.project.Workflow) + w.project.SecretProperties.StringData = map[string]string{} + for key, value := range secrets.Map() { + normalizedEnvNames, err := normalizeEnvNames(key) + if err != nil { + return err + } + for _, normalizedEnvName := range normalizedEnvNames { + w.project.SecretProperties.StringData[key] = value + env := corev1.EnvVar{ + Name: normalizedEnvName, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: w.project.SecretProperties.Name, + }, + Key: key, + }, + }, + } + w.project.Workflow.Spec.PodTemplate.Container.Env = append(w.project.Workflow.Spec.PodTemplate.Container.Env, env) + } + } + + if err = SetTypeToObject(w.project.SecretProperties, w.scheme); err != nil { + return err + } + return nil +} + func (w *workflowProjectHandler) parseRawResources() error { if len(w.rawResources) == 0 { return nil @@ -338,3 +407,57 @@ func isProfile(workflow *operatorapi.SonataFlow, profileType metadata.ProfileTyp } return metadata.ProfileType(profile) == profileType } + +var envProfilesExpr = regexp.MustCompile(`^%([A-Za-z0-9_-]+(?:,[A-Za-z0-9_-]+)*)\.(.+)$`) + +func normalizeEnvNames(names string) ([]string, error) { + matches := envProfilesExpr.FindStringSubmatch(names) + if matches == nil { + if normalized, err := normalizeEnvName(names); err != nil { + return nil, err + } else { + return []string{normalized}, nil + } + } else { + profiles := strings.Split(matches[1], ",") + propertyKey := matches[2] + var result []string + for _, profile := range profiles { + normalized, err := normalizeEnvName("%" + profile + "_" + propertyKey) + if err != nil { + return nil, err + } + result = append(result, normalized) + } + return result, nil + } +} + +func normalizeEnvName(original string) (string, error) { + name := original + name = strings.TrimSpace(name) + + replacer := strings.NewReplacer( + " ", "_", + "-", "_", + ".", "_", + "\"", "_", + "'", "_", + "[", "_", + "]", "_", + "%", "_", + ) + name = replacer.Replace(name) + name = strings.ToUpper(name) + + if strings.HasSuffix(name, "_") { + name = name[:len(name)-1] + } + + validName := regexp.MustCompile(`^[A-Z0-9_]+$`) + if !validName.MatchString(name) { + return "", fmt.Errorf("invalid environment variable name: %s (must only contain A-Z, 0-9, and _)", original) + } + + return name, nil +} diff --git a/packages/sonataflow-operator/workflowproj/workflowproj_test.go b/packages/sonataflow-operator/workflowproj/workflowproj_test.go index e028fba731f..d1f32559c65 100644 --- a/packages/sonataflow-operator/workflowproj/workflowproj_test.go +++ b/packages/sonataflow-operator/workflowproj/workflowproj_test.go @@ -28,6 +28,8 @@ import ( "strings" "testing" + "github.com/magiconair/properties" + "k8s.io/apimachinery/pkg/runtime/schema" "github.com/stretchr/testify/assert" @@ -134,6 +136,48 @@ func Test_Handler_WorkflowMinimalAndPropsAndSpecAndGeneric(t *testing.T) { assert.NotEmpty(t, data) } +func Test_Handler_WorkflowMinimalAndSecrets(t *testing.T) { + type env struct { + key string + secretKeyRefName string + } + proj, err := New("default"). + Named("minimal"). + Profile(metadata.PreviewProfile). + WithWorkflow(getWorkflowMinimal()). + WithSecretProperties(getWorkflowSecretProperties()). + AsObjects() + assert.NoError(t, err) + assert.NotNil(t, proj.Workflow) + assert.NotNil(t, proj.SecretProperties) + assert.Equal(t, "minimal", proj.Workflow.Name) + assert.Equal(t, "minimal-secrets", proj.SecretProperties.Name) + assert.NotEmpty(t, proj.SecretProperties.StringData) + + secretPropsContent, err := io.ReadAll(getWorkflowSecretProperties()) + assert.NoError(t, err) + secrets, err := properties.Load(secretPropsContent, properties.UTF8) + assert.NoError(t, err) + envs := map[string]env{} + + for _, value := range proj.Workflow.Spec.PodTemplate.Container.Env { + envs[value.Name] = env{key: value.ValueFrom.SecretKeyRef.Key, secretKeyRefName: value.ValueFrom.SecretKeyRef.Name} + } + + for k, v := range secrets.Map() { + assert.Equal(t, v, proj.SecretProperties.StringData[k]) + normalized, err := normalizeEnvNames(k) + for _, value := range normalized { + assert.NoError(t, err) + env, exists := envs[value] + assert.True(t, exists) + assert.Equal(t, k, env.key) + assert.Equal(t, proj.SecretProperties.Name, env.secretKeyRefName) + } + + } +} + func getResourceDataWithFileName(cms []*corev1.ConfigMap, fileName string) (string, error) { for i := range cms { if data, ok := cms[i].Data[fileName]; ok { @@ -242,6 +286,65 @@ func TestWorkflowProjectHandler_Image(t *testing.T) { assert.Equal(t, "host/namespace/service:latest", proj.Workflow.Spec.PodTemplate.Container.Image) } +func TestNormalizeEnvName(t *testing.T) { + type testCase struct { + input string + expected []string + error bool + } + tests := []testCase{ + {"my-env", []string{"MY_ENV"}, false}, + {"my.env.1", []string{"MY_ENV_1"}, false}, + {"my.env-1", []string{"MY_ENV_1"}, false}, + {"my-env.1", []string{"MY_ENV_1"}, false}, + {"my-env-1$", []string{""}, true}, + {"my-env-1&&", []string{""}, true}, + {"", []string{""}, true}, + {"$%&*", []string{""}, true}, + {"a", []string{"A"}, false}, + {"1", []string{"1"}, false}, + {"_", []string{""}, true}, + {"my env", []string{"MY_ENV"}, false}, + {" my env ", []string{"MY_ENV"}, false}, + {"-", []string{""}, true}, + {".", []string{""}, true}, + {"my-env-1234567890-long-name-with-dashes", []string{"MY_ENV_1234567890_LONG_NAME_WITH_DASHES"}, false}, + {"long-name-with-invalid-characters-@#$%^", []string{""}, true}, + {"my-env-1@name", []string{""}, true}, + {"A", []string{"A"}, false}, + {"a1_b2", []string{"A1_B2"}, false}, + {"a!!@#$b", []string{""}, true}, + {"foo.\"bar\".baz", []string{"FOO__BAR__BAZ"}, false}, + {"quarkus.'my-property'.foo", []string{"QUARKUS__MY_PROPERTY__FOO"}, false}, + {"myProperty[10]", []string{"MYPROPERTY_10"}, false}, + {"my.config[0].value", []string{"MY_CONFIG_0__VALUE"}, false}, + {"quarkus.\"myProperty\"[0].value", []string{"QUARKUS__MYPROPERTY__0__VALUE"}, false}, + {"quarkus.\"my-property\"[1].sub-name", []string{"QUARKUS__MY_PROPERTY__1__SUB_NAME"}, false}, + {"quarkus.myProperty..sub.value", []string{"QUARKUS_MYPROPERTY__SUB_VALUE"}, false}, + {"quarkus.[strange].key", []string{"QUARKUS__STRANGE__KEY"}, false}, + {"quarkus.datasource.\"datasource-name\".jdbc.url", []string{"QUARKUS_DATASOURCE__DATASOURCE_NAME__JDBC_URL"}, false}, + {"%dev.quarkus.http.port", []string{"_DEV_QUARKUS_HTTP_PORT"}, false}, + {"%staging.quarkus.http.test-port", []string{"_STAGING_QUARKUS_HTTP_TEST_PORT"}, false}, + {"%prod,dev.my.prop", []string{"_PROD_MY_PROP", "_DEV_MY_PROP"}, false}, + {"%prod,dev.quarkus.datasource.\"datasource-name\".jdbc.url", []string{"_PROD_QUARKUS_DATASOURCE__DATASOURCE_NAME__JDBC_URL", + "_DEV_QUARKUS_DATASOURCE__DATASOURCE_NAME__JDBC_URL"}, false}, + } + + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + actual, err := normalizeEnvNames(test.input) + if test.error { + assert.Error(t, err) + } else { + assert.NoError(t, err) + for index, expected := range test.expected { + assert.Equal(t, expected, actual[index]) + } + } + }) + } +} + func getWorkflowMinimalInvalid() io.Reader { return mustGetFile("testdata/workflows/workflow-minimal-invalid.sw.json") } @@ -266,6 +369,10 @@ func getSpecGeneric() io.Reader { return mustGetFile("testdata/workflows/specs/workflow-service-schema.json") } +func getWorkflowSecretProperties() io.Reader { + return mustGetFile("testdata/workflows/secret.properties") +} + func mustGetFile(filepath string) io.Reader { file, err := os.OpenFile(filepath, os.O_RDONLY, os.ModePerm) if err != nil {