From ed127d11cd404ddfde83cccd55ae4ecedba58981 Mon Sep 17 00:00:00 2001 From: Dmitrii Tikhomirov Date: Mon, 25 Nov 2024 01:00:11 -0800 Subject: [PATCH] apache_issues#2630: Extend kn-workflow CLI to Generate K8s secrets for workflows --- .../pkg/command/deploy_undeploy_common.go | 64 +++++++---- .../pkg/metadata/constants.go | 2 +- .../sonataflow-operator/workflowproj/go.mod | 1 + .../sonataflow-operator/workflowproj/go.sum | 1 + .../workflowproj/operator.go | 23 +++- .../testdata/workflows/secret.properties | 25 +++++ .../workflowproj/workflowproj.go | 101 ++++++++++++++++-- .../workflowproj/workflowproj_test.go | 88 +++++++++++++++ 8 files changed, 274 insertions(+), 31 deletions(-) create mode 100644 packages/sonataflow-operator/workflowproj/testdata/workflows/secret.properties 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 3519c8e99fc..c95fd67d30e 100644 --- a/packages/kn-plugin-workflow/pkg/command/deploy_undeploy_common.go +++ b/packages/kn-plugin-workflow/pkg/command/deploy_undeploy_common.go @@ -32,25 +32,26 @@ 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 + 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 } func checkEnvironment(cfg *DeployUndeployCmdConfig) error { @@ -121,6 +122,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...") @@ -184,6 +191,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 { @@ -248,6 +263,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..96cadf3f281 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,51 @@ 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() { + normalizedEnvName, err := normalizeEnvName(key) + if err != nil { + return err + } + + 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 +406,18 @@ func isProfile(workflow *operatorapi.SonataFlow, profileType metadata.ProfileTyp } return metadata.ProfileType(profile) == profileType } + +func normalizeEnvName(name string) (string, error) { + name = strings.TrimSpace(name) + + replacer := strings.NewReplacer(" ", "_", "-", "_", ".", "_") + name = replacer.Replace(name) + name = strings.ToUpper(name) + + validName := regexp.MustCompile(`^[A-Z0-9_]+$`) + if !validName.MatchString(name) || name == "_" { + return "", fmt.Errorf("invalid environment variable name: %s (must only contain A-Z, 0-9, and _)", name) + } + + return name, nil +} diff --git a/packages/sonataflow-operator/workflowproj/workflowproj_test.go b/packages/sonataflow-operator/workflowproj/workflowproj_test.go index e028fba731f..0f87d3bfa86 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,45 @@ 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 := normalizeEnvName(k) + assert.NoError(t, err) + env, exists := envs[normalized] + 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 +283,49 @@ 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", "MY_ENV", false}, + {"my.env.1", "MY_ENV_1", false}, + {"my.env-1", "MY_ENV_1", false}, + {"my-env.1", "MY_ENV_1", false}, + {"my-env-1$", "", true}, + {"my-env-1&&", "", true}, + {"", "", true}, + {"$%&*", "", true}, + {"a", "A", false}, + {"1", "1", false}, + {"_", "", true}, + {"my env", "MY_ENV", false}, + {" my env ", "MY_ENV", false}, + {"-", "", true}, + {".", "", true}, + {"my-env-1234567890-long-name-with-dashes", "MY_ENV_1234567890_LONG_NAME_WITH_DASHES", false}, + {"long-name-with-invalid-characters-@#$%^", "", true}, + {"my-env-1@name", "", true}, + {"A", "A", false}, + {"a1_b2", "A1_B2", false}, + {"a!!@#$b", "", true}, + } + + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + actual, err := normalizeEnvName(test.input) + if test.error { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, test.expected, actual) + } + }) + } +} + func getWorkflowMinimalInvalid() io.Reader { return mustGetFile("testdata/workflows/workflow-minimal-invalid.sw.json") } @@ -266,6 +350,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 {