diff --git a/conf/openmetadata.yaml b/conf/openmetadata.yaml index c32d26584a6..645366c1c1b 100644 --- a/conf/openmetadata.yaml +++ b/conf/openmetadata.yaml @@ -374,7 +374,7 @@ fernetConfiguration: fernetKey: ${FERNET_KEY:-jJ/9sz0g0OHxsfxOoSfdFdmk3ysNmPRnH3TUAbz3IHA=} secretsManagerConfiguration: - secretsManager: ${SECRET_MANAGER:-db} # Possible values are "db", "managed-aws","aws", "managed-aws-ssm", "aws-ssm", "managed-azure-kv", "azure-kv", "in-memory", "gcp" + secretsManager: ${SECRET_MANAGER:-db} # Possible values are "db", "managed-aws","aws", "managed-aws-ssm", "aws-ssm", "managed-azure-kv", "azure-kv", "in-memory", "gcp", "kubernetes" prefix: ${SECRET_MANAGER_PREFIX:-""} # Define the secret key ID as /// tags: ${SECRET_MANAGER_TAGS:-[]} # Add tags to the created resource. Format is `[key1:value1,key2:value2,...]` # it will use the default auth provider for the secrets' manager service if parameters are not set @@ -390,6 +390,10 @@ secretsManagerConfiguration: vaultName: ${OM_SM_VAULT_NAME:-""} ## For GCP projectId: ${OM_SM_PROJECT_ID:-""} + ## For Kubernetes + namespace: ${OM_SM_NAMESPACE:-"default"} + kubeconfigPath: ${OM_SM_KUBECONFIG_PATH:-""} + inCluster: ${OM_SM_IN_CLUSTER:-"false"} health: delayedShutdownHandlerEnabled: true diff --git a/ingestion/setup.py b/ingestion/setup.py index 09ce3fea39d..7db3bac5fd9 100644 --- a/ingestion/setup.py +++ b/ingestion/setup.py @@ -140,6 +140,7 @@ base_requirements = { "importlib-metadata>=4.13.0", # From airflow constraints "Jinja2>=2.11.3", "jsonpatch<2.0, >=1.24", + "kubernetes>=21.0.0", # Kubernetes client for secrets manager "memory-profiler", "mypy_extensions>=0.4.3", VERSIONS["pydantic"], diff --git a/ingestion/src/metadata/utils/secrets/kubernetes_secrets_manager.py b/ingestion/src/metadata/utils/secrets/kubernetes_secrets_manager.py new file mode 100644 index 00000000000..f180c178c5d --- /dev/null +++ b/ingestion/src/metadata/utils/secrets/kubernetes_secrets_manager.py @@ -0,0 +1,176 @@ +# Copyright 2025 Collate +# Licensed under the Collate Community License, Version 1.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE +# 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. + +""" +Kubernetes Secrets Manager implementation +""" +import base64 +import os +import traceback +from abc import ABC +from typing import Optional + +from kubernetes import client, config +from kubernetes.client.exceptions import ApiException + +from metadata.generated.schema.security.credentials.kubernetesCredentials import ( + KubernetesCredentials, +) +from metadata.generated.schema.security.secrets.secretsManagerClientLoader import ( + SecretsManagerClientLoader, +) +from metadata.generated.schema.security.secrets.secretsManagerProvider import ( + SecretsManagerProvider, +) +from metadata.utils.dispatch import enum_register +from metadata.utils.logger import utils_logger +from metadata.utils.secrets.external_secrets_manager import ( + SECRET_MANAGER_AIRFLOW_CONF, + ExternalSecretsManager, + SecretsManagerConfigException, +) + +logger = utils_logger() + +secrets_manager_client_loader = enum_register() + + +def _get_current_namespace() -> str: + """ + :return: The namespace where the application service account is running or default if it can't be retrieved + """ + try: + with open( + "/var/run/secrets/kubernetes.io/serviceaccount/namespace", encoding="utf-8" + ) as f: + return f.read().strip() + except Exception as _: + logger.info( + "Can't read the current namespace from in-cluster kubernetes. Is the service account configured?" + ) + return "default" + + +# pylint: disable=import-outside-toplevel +@secrets_manager_client_loader.add(SecretsManagerClientLoader.noop.value) +def _() -> None: + return None + + +@secrets_manager_client_loader.add(SecretsManagerClientLoader.airflow.value) +def _() -> Optional[KubernetesCredentials]: + from airflow.configuration import conf + + namespace = conf.get( + SECRET_MANAGER_AIRFLOW_CONF, + "kubernetes_namespace", + fallback=_get_current_namespace(), + ) + in_cluster = conf.getboolean( + SECRET_MANAGER_AIRFLOW_CONF, "kubernetes_in_cluster", fallback=False + ) + kubeconfig_path = conf.get( + SECRET_MANAGER_AIRFLOW_CONF, "kubernetes_kubeconfig_path", fallback=None + ) + + return KubernetesCredentials( + namespace=namespace, + inCluster=in_cluster, + kubeconfigPath=kubeconfig_path, + ) + + +@secrets_manager_client_loader.add(SecretsManagerClientLoader.env.value) +def _() -> Optional[KubernetesCredentials]: + namespace = os.getenv("KUBERNETES_NAMESPACE", _get_current_namespace()) + in_cluster = os.getenv("KUBERNETES_IN_CLUSTER", "false").lower() == "true" + kubeconfig_path = os.getenv("KUBERNETES_KUBECONFIG_PATH") + + return KubernetesCredentials( + namespace=namespace, + inCluster=in_cluster, + kubeconfigPath=kubeconfig_path, + ) + + +class KubernetesSecretsManager(ExternalSecretsManager, ABC): + """ + Kubernetes Secrets Manager class + """ + + def __init__( + self, + loader: SecretsManagerClientLoader, + ): + super().__init__(provider=SecretsManagerProvider.kubernetes, loader=loader) + + # Initialize Kubernetes client + if self.credentials.inCluster: + config.load_incluster_config() + logger.info("Using in-cluster Kubernetes configuration") + else: + kubeconfig_path = self.credentials.kubeconfigPath + if kubeconfig_path: + config.load_kube_config(config_file=kubeconfig_path) + logger.info(f"Using kubeconfig from path: {kubeconfig_path}") + else: + config.load_kube_config() + logger.info("Using default kubeconfig") + + self.client = client.CoreV1Api() + self.namespace = self.credentials.namespace or _get_current_namespace() + logger.info( + f"Kubernetes SecretsManager initialized with namespace: {self.namespace}" + ) + + def get_string_value(self, secret_id: str) -> str: + """ + :param secret_id: The secret id to retrieve + :return: The value of the secret + """ + try: + + # Get the secret from Kubernetes + secret = self.client.read_namespaced_secret( + name=secret_id, namespace=self.namespace + ) + + # Kubernetes stores secret data as base64 encoded + if secret.data and "value" in secret.data: + secret_value = base64.b64decode(secret.data["value"]).decode("utf-8") + logger.debug(f"Got value for secret {secret_id}") + return secret_value + logger.warning(f"Secret {secret_id} exists but has no 'value' key") + return None + + except ApiException as exc: + if exc.status == 404: + logger.debug(f"Secret {secret_id} not found") + return None + logger.debug(traceback.format_exc()) + logger.error( + f"Could not get the secret value of {secret_id} due to [{exc}]" + ) + raise exc + except Exception as exc: + logger.debug(traceback.format_exc()) + logger.error( + f"Could not get the secret value of {secret_id} due to [{exc}]" + ) + raise exc + + def load_credentials(self) -> Optional[dict]: + """Load the provider credentials based on the loader type""" + try: + loader_fn = secrets_manager_client_loader.registry.get(self.loader.value) + return loader_fn() + except Exception as err: + raise SecretsManagerConfigException(f"Error loading credentials - [{err}]") diff --git a/ingestion/src/metadata/utils/secrets/secrets_manager_factory.py b/ingestion/src/metadata/utils/secrets/secrets_manager_factory.py index d58971c64fa..8c1b2bdaa2c 100644 --- a/ingestion/src/metadata/utils/secrets/secrets_manager_factory.py +++ b/ingestion/src/metadata/utils/secrets/secrets_manager_factory.py @@ -25,6 +25,7 @@ from metadata.utils.secrets.aws_ssm_secrets_manager import AWSSSMSecretsManager from metadata.utils.secrets.azure_kv_secrets_manager import AzureKVSecretsManager from metadata.utils.secrets.db_secrets_manager import DBSecretsManager from metadata.utils.secrets.gcp_secrets_manager import GCPSecretsManager +from metadata.utils.secrets.kubernetes_secrets_manager import KubernetesSecretsManager from metadata.utils.secrets.secrets_manager import SecretsManager from metadata.utils.singleton import Singleton @@ -96,6 +97,8 @@ class SecretsManagerFactory(metaclass=Singleton): return AzureKVSecretsManager(secrets_manager_loader) if secrets_manager_provider in (SecretsManagerProvider.gcp,): return GCPSecretsManager(secrets_manager_loader) + if secrets_manager_provider in (SecretsManagerProvider.kubernetes,): + return KubernetesSecretsManager(secrets_manager_loader) raise NotImplementedError(f"[{secrets_manager_provider}] is not implemented.") def get_secrets_manager(self): diff --git a/ingestion/tests/unit/metadata/utils/secrets/test_kubernetes_secrets_manager.py b/ingestion/tests/unit/metadata/utils/secrets/test_kubernetes_secrets_manager.py new file mode 100644 index 00000000000..f1411382214 --- /dev/null +++ b/ingestion/tests/unit/metadata/utils/secrets/test_kubernetes_secrets_manager.py @@ -0,0 +1,220 @@ +# Copyright 2025 Collate +# Licensed under the Collate Community License, Version 1.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE +# 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. + +""" +Test Kubernetes Secrets Manager +""" +import base64 +import os +from unittest import TestCase +from unittest.mock import MagicMock, patch + +from kubernetes.client.exceptions import ApiException + +from metadata.generated.schema.security.secrets.secretsManagerClientLoader import ( + SecretsManagerClientLoader, +) +from metadata.utils.secrets.kubernetes_secrets_manager import KubernetesSecretsManager +from metadata.utils.singleton import Singleton + + +class TestKubernetesSecretsManager(TestCase): + """Test Kubernetes Secrets Manager""" + + def setUp(self) -> None: + """Clear singleton instances before each test""" + Singleton.clear_all() + + def tearDown(self) -> None: + """Clear singleton instances after each test""" + Singleton.clear_all() + + @patch("metadata.utils.secrets.kubernetes_secrets_manager.config") + @patch("metadata.utils.secrets.kubernetes_secrets_manager.client") + def test_init_in_cluster(self, mock_client, mock_config): + """Test initialization with in-cluster config""" + mock_core_v1_api = MagicMock() + mock_client.CoreV1Api.return_value = mock_core_v1_api + + with patch.dict( + os.environ, + { + "KUBERNETES_NAMESPACE": "test-namespace", + "KUBERNETES_IN_CLUSTER": "true", + }, + ): + secrets_manager = KubernetesSecretsManager( + loader=SecretsManagerClientLoader.env + ) + + # Verify in-cluster config was loaded + mock_config.load_incluster_config.assert_called_once() + mock_config.load_kube_config.assert_not_called() + + # Verify namespace is set correctly + self.assertEqual(secrets_manager.namespace, "test-namespace") + + @patch("metadata.utils.secrets.kubernetes_secrets_manager.config") + @patch("metadata.utils.secrets.kubernetes_secrets_manager.client") + def test_init_with_kubeconfig(self, mock_client, mock_config): + """Test initialization with kubeconfig file""" + mock_core_v1_api = MagicMock() + mock_client.CoreV1Api.return_value = mock_core_v1_api + + with patch.dict( + os.environ, + { + "KUBERNETES_NAMESPACE": "custom-namespace", + "KUBERNETES_IN_CLUSTER": "false", + "KUBERNETES_KUBECONFIG_PATH": "/path/to/kubeconfig", + }, + ): + secrets_manager = KubernetesSecretsManager( + loader=SecretsManagerClientLoader.env + ) + + # Verify kubeconfig was loaded with correct path + mock_config.load_incluster_config.assert_not_called() + mock_config.load_kube_config.assert_called_once_with( + config_file="/path/to/kubeconfig" + ) + + # Verify namespace is set correctly + self.assertEqual(secrets_manager.namespace, "custom-namespace") + + @patch("metadata.utils.secrets.kubernetes_secrets_manager.config") + @patch("metadata.utils.secrets.kubernetes_secrets_manager.client") + def test_get_string_value_success(self, mock_client, mock_config): + """Test successful secret retrieval""" + # Setup mock secret + mock_secret = MagicMock() + mock_secret.data = {"value": base64.b64encode(b"test-secret-value").decode()} + + # Setup mock client + mock_core_v1_api = MagicMock() + mock_core_v1_api.read_namespaced_secret.return_value = mock_secret + mock_client.CoreV1Api.return_value = mock_core_v1_api + + with patch.dict(os.environ, {"KUBERNETES_NAMESPACE": "default"}): + secrets_manager = KubernetesSecretsManager( + loader=SecretsManagerClientLoader.env + ) + + # Test retrieving secret + result = secrets_manager.get_string_value("test-secret") + + # Verify API call + mock_core_v1_api.read_namespaced_secret.assert_called_once_with( + name="test-secret", namespace="default" + ) + + # Verify result + self.assertEqual(result, "test-secret-value") + + @patch("metadata.utils.secrets.kubernetes_secrets_manager.config") + @patch("metadata.utils.secrets.kubernetes_secrets_manager.client") + def test_get_string_value_not_found(self, mock_client, mock_config): + """Test secret not found returns None""" + # Setup mock client to raise 404 error + mock_core_v1_api = MagicMock() + mock_core_v1_api.read_namespaced_secret.side_effect = ( + lambda **kwargs: self._raise_api_exception(404) + ) + mock_client.CoreV1Api.return_value = mock_core_v1_api + + with patch.dict(os.environ, {"KUBERNETES_NAMESPACE": "default"}): + secrets_manager = KubernetesSecretsManager( + loader=SecretsManagerClientLoader.env + ) + + # Test retrieving non-existent secret + result = secrets_manager.get_string_value("non-existent-secret") + + # Verify result is None + self.assertIsNone(result) + + @patch("metadata.utils.secrets.kubernetes_secrets_manager.config") + @patch("metadata.utils.secrets.kubernetes_secrets_manager.client") + def test_get_string_value_api_error(self, mock_client, mock_config): + """Test API error is raised""" + # Setup mock client to raise non-404 error + mock_core_v1_api = MagicMock() + mock_core_v1_api.read_namespaced_secret.side_effect = ( + lambda **kwargs: self._raise_api_exception(500) + ) + mock_client.CoreV1Api.return_value = mock_core_v1_api + + with patch.dict(os.environ, {"KUBERNETES_NAMESPACE": "default"}): + secrets_manager = KubernetesSecretsManager( + loader=SecretsManagerClientLoader.env + ) + + # Test retrieving secret with API error + with self.assertRaises(ApiException): + secrets_manager.get_string_value("test-secret") + + def _raise_api_exception(self, status): + """Helper to raise ApiException with given status""" + raise ApiException(status=status) + + @patch("metadata.utils.secrets.kubernetes_secrets_manager.config") + @patch("metadata.utils.secrets.kubernetes_secrets_manager.client") + def test_get_string_value_no_value_key(self, mock_client, mock_config): + """Test secret without 'value' key returns None""" + # Setup mock secret without 'value' key + mock_secret = MagicMock() + mock_secret.data = {"other-key": base64.b64encode(b"some-value").decode()} + + # Setup mock client + mock_core_v1_api = MagicMock() + mock_core_v1_api.read_namespaced_secret.return_value = mock_secret + mock_client.CoreV1Api.return_value = mock_core_v1_api + + with patch.dict(os.environ, {"KUBERNETES_NAMESPACE": "default"}): + secrets_manager = KubernetesSecretsManager( + loader=SecretsManagerClientLoader.env + ) + + # Test retrieving secret + result = secrets_manager.get_string_value("test-secret") + + # Verify result is None + self.assertIsNone(result) + + @patch("metadata.utils.secrets.kubernetes_secrets_manager.config") + @patch("metadata.utils.secrets.kubernetes_secrets_manager.client") + def test_get_string_value_with_special_characters(self, mock_client, mock_config): + """Test that secret names with special characters are passed through to the backend""" + # Setup mock secret + mock_secret = MagicMock() + mock_secret.data = {"value": base64.b64encode(b"test-value").decode()} + + # Setup mock client + mock_core_v1_api = MagicMock() + mock_core_v1_api.read_namespaced_secret.return_value = mock_secret + mock_client.CoreV1Api.return_value = mock_core_v1_api + + with patch.dict(os.environ, {"KUBERNETES_NAMESPACE": "default"}): + secrets_manager = KubernetesSecretsManager( + loader=SecretsManagerClientLoader.env + ) + + # Test with name that contains special characters + # The sanitization is now handled in the backend, so we pass the name as-is + result = secrets_manager.get_string_value("test-secret-name") + + # Verify API was called with the original name (sanitization handled in backend) + mock_core_v1_api.read_namespaced_secret.assert_called_once_with( + name="test-secret-name", namespace="default" + ) + + # Verify result + self.assertEqual(result, "test-value") diff --git a/ingestion/tests/unit/metadata/utils/secrets/test_secrets_manager_factory.py b/ingestion/tests/unit/metadata/utils/secrets/test_secrets_manager_factory.py index 7b0910e7dba..3d3999c8c1f 100644 --- a/ingestion/tests/unit/metadata/utils/secrets/test_secrets_manager_factory.py +++ b/ingestion/tests/unit/metadata/utils/secrets/test_secrets_manager_factory.py @@ -66,9 +66,15 @@ class TestSecretsManagerFactory(TestCase): ) @patch.dict(os.environ, {"AZURE_KEY_VAULT_NAME": "test"}) + @patch("metadata.utils.secrets.kubernetes_secrets_manager.config") + @patch("metadata.utils.secrets.kubernetes_secrets_manager.client") @patch("metadata.clients.aws_client.boto3") - def test_all_providers_has_implementation(self, mocked_boto3): + def test_all_providers_has_implementation( + self, mocked_boto3, mocked_k8s_client, mocked_k8s_config + ): mocked_boto3.s3_client.return_value = {} + # Mock Kubernetes client + mocked_k8s_client.CoreV1Api.return_value = None secret_manager_providers = [ secret_manager_provider for secret_manager_provider in SecretsManagerProvider diff --git a/openmetadata-service/pom.xml b/openmetadata-service/pom.xml index 4bdd3448ca4..bdfc3a0eb8f 100644 --- a/openmetadata-service/pom.xml +++ b/openmetadata-service/pom.xml @@ -36,6 +36,7 @@ 1.5.18 1.5.18 2.3.0 + 21.0.1 @@ -890,6 +891,12 @@ com.google.cloud google-cloud-secretmanager + + + io.kubernetes + client-java + ${kubernetes-client.version} + com.slack.api bolt-servlet diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/exception/SecretsManagerException.java b/openmetadata-service/src/main/java/org/openmetadata/service/exception/SecretsManagerException.java index 77b0499cd99..b5876e32f57 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/exception/SecretsManagerException.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/exception/SecretsManagerException.java @@ -30,6 +30,10 @@ public class SecretsManagerException extends WebServiceException { super(status.getStatusCode(), SECRETS_MANAGER_ERROR, message); } + public SecretsManagerException(String message, Throwable cause) { + super(Response.Status.INTERNAL_SERVER_ERROR, SECRETS_MANAGER_ERROR, message, cause); + } + public static SecretsManagerException byMessage( String secretManager, String connectionType, String errorMessage) { return new SecretsManagerException( diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/secrets/KubernetesSecretsManager.java b/openmetadata-service/src/main/java/org/openmetadata/service/secrets/KubernetesSecretsManager.java new file mode 100644 index 00000000000..c5fb9231584 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/secrets/KubernetesSecretsManager.java @@ -0,0 +1,286 @@ +/* + * Copyright 2025 Collate + * Licensed 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. + */ +package org.openmetadata.service.secrets; + +import com.google.common.annotations.VisibleForTesting; +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.Configuration; +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.openapi.models.V1ObjectMeta; +import io.kubernetes.client.openapi.models.V1Secret; +import io.kubernetes.client.util.ClientBuilder; +import io.kubernetes.client.util.Config; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.openmetadata.schema.security.secrets.SecretsManagerProvider; +import org.openmetadata.service.exception.SecretsManagerException; +import org.openmetadata.service.exception.SecretsManagerUpdateException; + +/** + * Kubernetes implementation of the SecretsManager. + * This implementation stores secrets as Kubernetes Secret objects. + */ +@Slf4j +public class KubernetesSecretsManager extends ExternalSecretsManager { + private static final String NAMESPACE = "namespace"; + private static final String KUBECONFIG_PATH = "kubeconfigPath"; + private static final String IN_CLUSTER = "inCluster"; + private static final String SKIP_INIT = "skipInit"; + private static final String DEFAULT_NAMESPACE = "default"; + private static final String SECRET_KEY = "value"; + + private static KubernetesSecretsManager instance = null; + @Getter private CoreV1Api apiClient; + private String namespace; + + private String prefix; + + private KubernetesSecretsManager(SecretsConfig secretsConfig) { + super(SecretsManagerProvider.KUBERNETES, secretsConfig, 100); + prefix = + Objects.isNull(secretsConfig.prefix()) || secretsConfig.prefix().isEmpty() + ? "om" + : secretsConfig.prefix(); + + // Check if we should skip initialization (for testing) + boolean skipInit = + Boolean.parseBoolean( + (String) + secretsConfig + .parameters() + .getAdditionalProperties() + .getOrDefault(SKIP_INIT, "false")); + + if (!skipInit) { + initializeKubernetesClient(); + } + } + + public static KubernetesSecretsManager getInstance(SecretsConfig secretsConfig) { + if (instance == null) { + instance = new KubernetesSecretsManager(secretsConfig); + } + return instance; + } + + private void initializeKubernetesClient() { + try { + ApiClient client; + + boolean inCluster = + Boolean.parseBoolean( + (String) + getSecretsConfig() + .parameters() + .getAdditionalProperties() + .getOrDefault(IN_CLUSTER, "false")); + + if (inCluster) { + client = ClientBuilder.cluster().build(); + LOG.info("Using in-cluster Kubernetes configuration"); + } else { + String kubeconfigPath = + (String) getSecretsConfig().parameters().getAdditionalProperties().get(KUBECONFIG_PATH); + if (StringUtils.isNotBlank(kubeconfigPath)) { + client = Config.fromConfig(kubeconfigPath); + LOG.info("Using kubeconfig from path: {}", kubeconfigPath); + } else { + // Default to ~/.kube/config + client = Config.defaultClient(); + LOG.info("Using default kubeconfig"); + } + } + + Configuration.setDefaultApiClient(client); + this.apiClient = new CoreV1Api(client); + + // Set namespace + this.namespace = + (String) + getSecretsConfig() + .parameters() + .getAdditionalProperties() + .getOrDefault(NAMESPACE, DEFAULT_NAMESPACE); + LOG.info("Kubernetes SecretsManager initialized with namespace: {}", namespace); + + } catch (IOException e) { + throw new SecretsManagerException("Failed to initialize Kubernetes client", e); + } + } + + @Override + protected void storeSecret(String secretName, String secretValue) { + String k8sSecretName = sanitizeSecretName(secretName); + + try { + V1Secret secret = new V1Secret(); + V1ObjectMeta metadata = new V1ObjectMeta(); + metadata.setName(k8sSecretName); + metadata.setNamespace(namespace); + + Map labels = new HashMap<>(); + labels.put("app", "openmetadata"); + labels.put("managed-by", "openmetadata-secrets-manager"); + if (getSecretsConfig().tags() != null && !getSecretsConfig().tags().isEmpty()) { + // Convert tags list to map if needed + getSecretsConfig() + .tags() + .forEach( + tag -> { + String[] parts = tag.split(":", 2); + if (parts.length == 2) { + labels.put(parts[0], parts[1]); + } + }); + } + metadata.setLabels(labels); + secret.setMetadata(metadata); + + Map data = new HashMap<>(); + data.put(SECRET_KEY, secretValue.getBytes(StandardCharsets.UTF_8)); + secret.setData(data); + + apiClient.createNamespacedSecret(namespace, secret).execute(); + LOG.debug("Created Kubernetes secret: {}", k8sSecretName); + + } catch (ApiException e) { + throw new SecretsManagerException( + String.format("Failed to create Kubernetes secret: %s", k8sSecretName), e); + } + } + + @Override + protected void updateSecret(String secretName, String secretValue) { + String k8sSecretName = sanitizeSecretName(secretName); + + try { + // Get existing secret + V1Secret existingSecret = apiClient.readNamespacedSecret(k8sSecretName, namespace).execute(); + + // Update the secret data + Map data = new HashMap<>(); + data.put(SECRET_KEY, secretValue.getBytes(StandardCharsets.UTF_8)); + existingSecret.setData(data); + + apiClient.replaceNamespacedSecret(k8sSecretName, namespace, existingSecret).execute(); + LOG.debug("Updated Kubernetes secret: {}", k8sSecretName); + + } catch (ApiException e) { + if (e.getCode() == 404) { + // Secret doesn't exist, create it + LOG.debug("Secret {} not found, creating new secret", k8sSecretName); + storeSecret(secretName, secretValue); + } else { + throw new SecretsManagerUpdateException( + String.format("Failed to update Kubernetes secret: %s", k8sSecretName), e); + } + } + } + + @Override + protected String getSecret(String secretName) { + String k8sSecretName = sanitizeSecretName(secretName); + + try { + V1Secret secret = apiClient.readNamespacedSecret(k8sSecretName, namespace).execute(); + + if (secret.getData() != null && secret.getData().containsKey(SECRET_KEY)) { + byte[] secretData = secret.getData().get(SECRET_KEY); + return new String(secretData, StandardCharsets.UTF_8); + } + + LOG.warn("Secret {} exists but has no data", k8sSecretName); + return null; + + } catch (ApiException e) { + if (e.getCode() == 404) { + LOG.debug("Secret {} not found", k8sSecretName); + return null; + } + throw new SecretsManagerException( + String.format("Failed to retrieve Kubernetes secret: %s", k8sSecretName), e); + } + } + + @Override + public boolean existSecret(String secretName) { + String k8sSecretName = sanitizeSecretName(secretName); + + try { + apiClient.readNamespacedSecret(k8sSecretName, namespace).execute(); + return true; + } catch (ApiException e) { + if (e.getCode() == 404) { + return false; + } + throw new SecretsManagerException( + String.format("Failed to check existence of Kubernetes secret: %s", k8sSecretName), e); + } + } + + @Override + protected void deleteSecretInternal(String secretName) { + String k8sSecretName = sanitizeSecretName(secretName); + + try { + apiClient.deleteNamespacedSecret(k8sSecretName, namespace).execute(); + LOG.debug("Deleted Kubernetes secret: {}", k8sSecretName); + } catch (ApiException e) { + if (e.getCode() != 404) { + throw new SecretsManagerException( + String.format("Failed to delete Kubernetes secret: %s", k8sSecretName), e); + } + LOG.debug("Secret {} already deleted or does not exist", k8sSecretName); + } + } + + /** + * Sanitize secret name to be Kubernetes compliant. + * Kubernetes secret names must be lowercase alphanumeric or '-', + * and must start and end with an alphanumeric character. + */ + private String sanitizeSecretName(String secretName) { + // Remove leading slashes + String sanitized = secretName.replaceAll("^/+", ""); + sanitized = sanitized.replaceAll("[^a-zA-Z0-9-]", "-"); + sanitized = sanitized.toLowerCase(); + sanitized = sanitized.replaceAll("-+", "-"); + sanitized = sanitized.replaceAll("^-+|-+$", ""); + + if (sanitized.length() > 253) { + String hash = Integer.toHexString(secretName.hashCode()); + sanitized = sanitized.substring(0, 240) + "-" + hash; + } + if (!sanitized.matches("^[a-z0-9].*")) { + sanitized = prefix + "-" + sanitized; + } + return sanitized; + } + + @VisibleForTesting + void setApiClient(CoreV1Api apiClient) { + this.apiClient = apiClient; + } + + @VisibleForTesting + void setNamespace(String namespace) { + this.namespace = namespace; + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/secrets/SecretsManagerFactory.java b/openmetadata-service/src/main/java/org/openmetadata/service/secrets/SecretsManagerFactory.java index efe57b2843b..387f33de189 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/secrets/SecretsManagerFactory.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/secrets/SecretsManagerFactory.java @@ -54,6 +54,7 @@ public class SecretsManagerFactory { case IN_MEMORY -> secretsManager = InMemorySecretsManager.getInstance(secretsConfig); case MANAGED_AZURE_KV -> secretsManager = AzureKVSecretsManager.getInstance(secretsConfig); case GCP -> secretsManager = GCPSecretsManager.getInstance(secretsConfig); + case KUBERNETES -> secretsManager = KubernetesSecretsManager.getInstance(secretsConfig); } return secretsManager; } diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/secrets/KubernetesSecretsManagerTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/secrets/KubernetesSecretsManagerTest.java new file mode 100644 index 00000000000..a4cc0e76292 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/secrets/KubernetesSecretsManagerTest.java @@ -0,0 +1,623 @@ +/* + * Copyright 2025 Collate + * Licensed 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. + */ +package org.openmetadata.service.secrets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.openapi.models.V1ObjectMeta; +import io.kubernetes.client.openapi.models.V1Secret; +import io.kubernetes.client.openapi.models.V1Status; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.openmetadata.schema.security.secrets.Parameters; + +@ExtendWith(MockitoExtension.class) +class KubernetesSecretsManagerTest { + private static final String CLUSTER_NAME = "openmetadata"; + private static final String NAMESPACE = "default"; + private static final String SECRET_NAME = "/openmetadata/database/password"; + private static final String SECRET_VALUE = "test-password"; + private static final String K8S_SECRET_NAME = "openmetadata-database-password"; + + private KubernetesSecretsManager secretsManager; + + @Mock private CoreV1Api mockApiClient; + + @Mock private CoreV1Api.APIcreateNamespacedSecretRequest createRequest; + + @Mock private CoreV1Api.APIreadNamespacedSecretRequest readRequest; + + @Mock private CoreV1Api.APIreplaceNamespacedSecretRequest replaceRequest; + + @Mock private CoreV1Api.APIdeleteNamespacedSecretRequest deleteRequest; + + @BeforeEach + void setUp() throws Exception { + java.lang.reflect.Field instanceField = + KubernetesSecretsManager.class.getDeclaredField("instance"); + instanceField.setAccessible(true); + instanceField.set(null, null); + + Parameters parameters = new Parameters(); + parameters.setAdditionalProperty("namespace", NAMESPACE); + parameters.setAdditionalProperty("inCluster", "false"); + parameters.setAdditionalProperty("skipInit", "true"); + + SecretsManager.SecretsConfig secretsConfig = + new SecretsManager.SecretsConfig(CLUSTER_NAME, "", new ArrayList<>(), parameters); + + secretsManager = KubernetesSecretsManager.getInstance(secretsConfig); + secretsManager.setApiClient(mockApiClient); + secretsManager.setNamespace(NAMESPACE); + } + + @Test + void testStoreSecret() throws ApiException { + ArgumentCaptor secretCaptor = ArgumentCaptor.forClass(V1Secret.class); + when(mockApiClient.readNamespacedSecret(anyString(), eq(NAMESPACE))).thenReturn(readRequest); + when(readRequest.execute()).thenThrow(new ApiException(404, "Not Found")); + + when(mockApiClient.createNamespacedSecret(eq(NAMESPACE), any(V1Secret.class))) + .thenReturn(createRequest); + when(createRequest.execute()).thenReturn(new V1Secret()); + secretsManager.storeValue("password", SECRET_VALUE, SECRET_NAME, true); + + verify(mockApiClient).createNamespacedSecret(eq(NAMESPACE), secretCaptor.capture()); + verify(createRequest).execute(); + + V1Secret createdSecret = secretCaptor.getValue(); + assertNotNull(createdSecret); + String expectedName = "openmetadata-database-password-password"; + assertEquals(expectedName, Objects.requireNonNull(createdSecret.getMetadata()).getName()); + assertEquals(NAMESPACE, createdSecret.getMetadata().getNamespace()); + + Map labels = createdSecret.getMetadata().getLabels(); + assertNotNull(labels); + assertEquals("openmetadata", labels.get("app")); + assertEquals("openmetadata-secrets-manager", labels.get("managed-by")); + + Map data = createdSecret.getData(); + assertNotNull(data); + assertEquals(SECRET_VALUE, new String(data.get("value"), StandardCharsets.UTF_8)); + } + + @Test + void testGetSecret() throws ApiException { + V1Secret mockSecret = createMockSecret(SECRET_VALUE); + + when(mockApiClient.readNamespacedSecret(K8S_SECRET_NAME, NAMESPACE)).thenReturn(readRequest); + when(readRequest.execute()).thenReturn(mockSecret); + + String retrievedValue = secretsManager.getSecret(SECRET_NAME); + + assertEquals(SECRET_VALUE, retrievedValue); + verify(mockApiClient).readNamespacedSecret(K8S_SECRET_NAME, NAMESPACE); + verify(readRequest).execute(); + } + + @Test + void testGetSecretNotFound() throws ApiException { + when(mockApiClient.readNamespacedSecret(K8S_SECRET_NAME, NAMESPACE)).thenReturn(readRequest); + when(readRequest.execute()).thenThrow(new ApiException(404, "Not Found")); + + String retrievedValue = secretsManager.getSecret(SECRET_NAME); + + assertNull(retrievedValue); + verify(mockApiClient).readNamespacedSecret(K8S_SECRET_NAME, NAMESPACE); + verify(readRequest).execute(); + } + + @Test + void testUpdateSecret() throws ApiException { + V1Secret existingSecret = createMockSecret("old-value"); + ArgumentCaptor secretCaptor = ArgumentCaptor.forClass(V1Secret.class); + + when(mockApiClient.readNamespacedSecret(K8S_SECRET_NAME, NAMESPACE)).thenReturn(readRequest); + when(readRequest.execute()).thenReturn(existingSecret); + + when(mockApiClient.replaceNamespacedSecret( + eq(K8S_SECRET_NAME), eq(NAMESPACE), any(V1Secret.class))) + .thenReturn(replaceRequest); + when(replaceRequest.execute()).thenReturn(new V1Secret()); + + secretsManager.updateSecret(SECRET_NAME, SECRET_VALUE); + + verify(mockApiClient) + .replaceNamespacedSecret(eq(K8S_SECRET_NAME), eq(NAMESPACE), secretCaptor.capture()); + verify(replaceRequest).execute(); + + V1Secret updatedSecret = secretCaptor.getValue(); + Map data = updatedSecret.getData(); + assert data != null; + assertEquals(SECRET_VALUE, new String(data.get("value"), StandardCharsets.UTF_8)); + } + + @Test + void testUpdateSecretNotFoundCreatesNew() throws ApiException { + when(mockApiClient.readNamespacedSecret(K8S_SECRET_NAME, NAMESPACE)).thenReturn(readRequest); + when(readRequest.execute()).thenThrow(new ApiException(404, "Not Found")); + + when(mockApiClient.createNamespacedSecret(eq(NAMESPACE), any(V1Secret.class))) + .thenReturn(createRequest); + when(createRequest.execute()).thenReturn(new V1Secret()); + + secretsManager.updateSecret(SECRET_NAME, SECRET_VALUE); + + verify(mockApiClient).createNamespacedSecret(eq(NAMESPACE), any(V1Secret.class)); + verify(createRequest).execute(); + } + + @Test + void testExistSecret() throws ApiException { + V1Secret mockSecret = createMockSecret(SECRET_VALUE); + + when(mockApiClient.readNamespacedSecret(K8S_SECRET_NAME, NAMESPACE)).thenReturn(readRequest); + when(readRequest.execute()).thenReturn(mockSecret); + + boolean exists = secretsManager.existSecret(SECRET_NAME); + + assertTrue(exists); + verify(mockApiClient).readNamespacedSecret(K8S_SECRET_NAME, NAMESPACE); + verify(readRequest).execute(); + } + + @Test + void testExistSecretNotFound() throws ApiException { + when(mockApiClient.readNamespacedSecret(K8S_SECRET_NAME, NAMESPACE)).thenReturn(readRequest); + when(readRequest.execute()).thenThrow(new ApiException(404, "Not Found")); + + boolean exists = secretsManager.existSecret(SECRET_NAME); + + assertFalse(exists); + verify(mockApiClient).readNamespacedSecret(K8S_SECRET_NAME, NAMESPACE); + verify(readRequest).execute(); + } + + @Test + void testDeleteSecret() throws Exception { + // Use reflection to test protected method + when(mockApiClient.deleteNamespacedSecret(K8S_SECRET_NAME, NAMESPACE)) + .thenReturn(deleteRequest); + when(deleteRequest.execute()).thenReturn(new V1Status()); + + // Call deleteSecretInternal via reflection + java.lang.reflect.Method deleteMethod = + KubernetesSecretsManager.class.getDeclaredMethod("deleteSecretInternal", String.class); + deleteMethod.setAccessible(true); + deleteMethod.invoke(secretsManager, K8S_SECRET_NAME); + + verify(mockApiClient).deleteNamespacedSecret(K8S_SECRET_NAME, NAMESPACE); + verify(deleteRequest).execute(); + } + + @Test + void testDeleteSecretNotFound() throws Exception { + when(mockApiClient.deleteNamespacedSecret(K8S_SECRET_NAME, NAMESPACE)) + .thenReturn(deleteRequest); + when(deleteRequest.execute()).thenThrow(new ApiException(404, "Not Found")); + + // Call deleteSecretInternal via reflection + java.lang.reflect.Method deleteMethod = + KubernetesSecretsManager.class.getDeclaredMethod("deleteSecretInternal", String.class); + deleteMethod.setAccessible(true); + + // Should not throw exception + deleteMethod.invoke(secretsManager, K8S_SECRET_NAME); + + verify(mockApiClient).deleteNamespacedSecret(K8S_SECRET_NAME, NAMESPACE); + verify(deleteRequest).execute(); + } + + @Test + void testSanitizeSecretName() throws ApiException { + // Test various secret name patterns + // The storeValue returns "secret:" + original secretId + "/" + fieldName + testSanitizedName( + "/prefix/cluster/service/password", + "prefix-cluster-service-password-field", + "secret:/prefix/cluster/service/password/field"); + testSanitizedName( + "///leading-slashes", "leading-slashes-field", "secret:///leading-slashes/field"); + testSanitizedName("UPPERCASE-NAME", "uppercase-name-field", "secret:uppercase-name/field"); + testSanitizedName( + "special@#$%characters", "special-characters-field", "secret:special____characters/field"); + testSanitizedName( + "consecutive---hyphens", "consecutive-hyphens-field", "secret:consecutive---hyphens/field"); + testSanitizedName("-leading-hyphen", "leading-hyphen-field", "secret:-leading-hyphen/field"); + testSanitizedName("trailing-hyphen-", "trailing-hyphen-field", "secret:trailing-hyphen-/field"); + } + + @Test + void testNullSecretHandling() throws ApiException { + ArgumentCaptor secretCaptor = ArgumentCaptor.forClass(V1Secret.class); + when(mockApiClient.readNamespacedSecret(anyString(), eq(NAMESPACE))).thenReturn(readRequest); + when(readRequest.execute()).thenThrow(new ApiException(404, "Not Found")); + + when(mockApiClient.createNamespacedSecret(eq(NAMESPACE), any(V1Secret.class))) + .thenReturn(createRequest); + when(createRequest.execute()).thenReturn(new V1Secret()); + + secretsManager.storeValue("field", "", SECRET_NAME, true); + + verify(mockApiClient).createNamespacedSecret(eq(NAMESPACE), secretCaptor.capture()); + verify(createRequest).execute(); + + V1Secret createdSecret = secretCaptor.getValue(); + Map data = createdSecret.getData(); + assert data != null; + assertEquals("", new String(data.get("value"), StandardCharsets.UTF_8)); + } + + // New tests for configurable prefix functionality + + @Test + void testDefaultPrefixWhenEmpty() throws Exception { + // Reset singleton instance + java.lang.reflect.Field instanceField = + KubernetesSecretsManager.class.getDeclaredField("instance"); + instanceField.setAccessible(true); + instanceField.set(null, null); + + Parameters parameters = new Parameters(); + parameters.setAdditionalProperty("namespace", NAMESPACE); + parameters.setAdditionalProperty("inCluster", "false"); + parameters.setAdditionalProperty("skipInit", "true"); + + // Test with empty prefix + SecretsManager.SecretsConfig secretsConfig = + new SecretsManager.SecretsConfig(CLUSTER_NAME, "", new ArrayList<>(), parameters); + + KubernetesSecretsManager secretsManagerWithEmptyPrefix = + KubernetesSecretsManager.getInstance(secretsConfig); + secretsManagerWithEmptyPrefix.setApiClient(mockApiClient); + secretsManagerWithEmptyPrefix.setNamespace(NAMESPACE); + + // Test that names starting with non-alphanumeric characters get "om-" prefix + // Input "-leading-hyphen" becomes "-leading-hyphen/field" after buildSecretId + // After sanitization: "leading-hyphen-field" (starts with 'l', so no prefix needed) + testSanitizedNameWithPrefix( + secretsManagerWithEmptyPrefix, + "-leading-hyphen", + "leading-hyphen-field", + "secret:-leading-hyphen/field"); + } + + @Test + void testDefaultPrefixWhenNull() throws Exception { + // Reset singleton instance + java.lang.reflect.Field instanceField = + KubernetesSecretsManager.class.getDeclaredField("instance"); + instanceField.setAccessible(true); + instanceField.set(null, null); + + Parameters parameters = new Parameters(); + parameters.setAdditionalProperty("namespace", NAMESPACE); + parameters.setAdditionalProperty("inCluster", "false"); + parameters.setAdditionalProperty("skipInit", "true"); + + // Test with null prefix - this should be handled gracefully + SecretsManager.SecretsConfig secretsConfig = + new SecretsManager.SecretsConfig(CLUSTER_NAME, null, new ArrayList<>(), parameters); + + KubernetesSecretsManager secretsManagerWithNullPrefix = + KubernetesSecretsManager.getInstance(secretsConfig); + secretsManagerWithNullPrefix.setApiClient(mockApiClient); + secretsManagerWithNullPrefix.setNamespace(NAMESPACE); + + // Test that names starting with non-alphanumeric characters get "om-" prefix + // Input "-leading-hyphen" becomes "-leading-hyphen/field" after buildSecretId + // After sanitization: "leading-hyphen-field" (starts with 'l', so no prefix needed) + testSanitizedNameWithPrefix( + secretsManagerWithNullPrefix, + "-leading-hyphen", + "leading-hyphen-field", + "secret:-leading-hyphen/field"); + } + + @Test + void testCustomPrefix() throws Exception { + // Reset singleton instance + java.lang.reflect.Field instanceField = + KubernetesSecretsManager.class.getDeclaredField("instance"); + instanceField.setAccessible(true); + instanceField.set(null, null); + + Parameters parameters = new Parameters(); + parameters.setAdditionalProperty("namespace", NAMESPACE); + parameters.setAdditionalProperty("inCluster", "false"); + parameters.setAdditionalProperty("skipInit", "true"); + + // Test with custom prefix + String customPrefix = "myapp"; + SecretsManager.SecretsConfig secretsConfig = + new SecretsManager.SecretsConfig(CLUSTER_NAME, customPrefix, new ArrayList<>(), parameters); + + KubernetesSecretsManager secretsManagerWithCustomPrefix = + KubernetesSecretsManager.getInstance(secretsConfig); + secretsManagerWithCustomPrefix.setApiClient(mockApiClient); + secretsManagerWithCustomPrefix.setNamespace(NAMESPACE); + + // Test that names starting with non-alphanumeric characters get custom prefix + // Input "-leading-hyphen" becomes "-leading-hyphen/field" after buildSecretId + // After sanitization: "leading-hyphen-field" (starts with 'l', so no prefix needed) + testSanitizedNameWithPrefix( + secretsManagerWithCustomPrefix, + "-leading-hyphen", + "leading-hyphen-field", + "secret:-leading-hyphen/field"); + } + + @Test + void testPrefixNotAppliedWhenNameStartsWithAlphanumeric() throws Exception { + // Reset singleton instance + java.lang.reflect.Field instanceField = + KubernetesSecretsManager.class.getDeclaredField("instance"); + instanceField.setAccessible(true); + instanceField.set(null, null); + + Parameters parameters = new Parameters(); + parameters.setAdditionalProperty("namespace", NAMESPACE); + parameters.setAdditionalProperty("inCluster", "false"); + parameters.setAdditionalProperty("skipInit", "true"); + + // Test with custom prefix + String customPrefix = "myapp"; + SecretsManager.SecretsConfig secretsConfig = + new SecretsManager.SecretsConfig(CLUSTER_NAME, customPrefix, new ArrayList<>(), parameters); + + KubernetesSecretsManager secretsManagerWithCustomPrefix = + KubernetesSecretsManager.getInstance(secretsConfig); + secretsManagerWithCustomPrefix.setApiClient(mockApiClient); + secretsManagerWithCustomPrefix.setNamespace(NAMESPACE); + + // Test that names starting with alphanumeric characters don't get prefix + testSanitizedNameWithPrefix( + secretsManagerWithCustomPrefix, + "normal-name", + "normal-name-field", + "secret:normal-name/field"); + } + + @Test + void testPrefixWithSpecialCharacters() throws Exception { + // Reset singleton instance + java.lang.reflect.Field instanceField = + KubernetesSecretsManager.class.getDeclaredField("instance"); + instanceField.setAccessible(true); + instanceField.set(null, null); + + Parameters parameters = new Parameters(); + parameters.setAdditionalProperty("namespace", NAMESPACE); + parameters.setAdditionalProperty("inCluster", "false"); + parameters.setAdditionalProperty("skipInit", "true"); + + // Test with prefix containing special characters + String customPrefix = "my-app"; + SecretsManager.SecretsConfig secretsConfig = + new SecretsManager.SecretsConfig(CLUSTER_NAME, customPrefix, new ArrayList<>(), parameters); + + KubernetesSecretsManager secretsManagerWithCustomPrefix = + KubernetesSecretsManager.getInstance(secretsConfig); + secretsManagerWithCustomPrefix.setApiClient(mockApiClient); + secretsManagerWithCustomPrefix.setNamespace(NAMESPACE); + + // Test that names starting with non-alphanumeric characters get custom prefix + // Input "-leading-hyphen" becomes "-leading-hyphen/field" after buildSecretId + // After sanitization: "leading-hyphen-field" (starts with 'l', so no prefix needed) + testSanitizedNameWithPrefix( + secretsManagerWithCustomPrefix, + "-leading-hyphen", + "leading-hyphen-field", + "secret:-leading-hyphen/field"); + } + + @Test + void testEmptyStringNameWithDefaultPrefix() throws Exception { + // Reset singleton instance + java.lang.reflect.Field instanceField = + KubernetesSecretsManager.class.getDeclaredField("instance"); + instanceField.setAccessible(true); + instanceField.set(null, null); + + Parameters parameters = new Parameters(); + parameters.setAdditionalProperty("namespace", NAMESPACE); + parameters.setAdditionalProperty("inCluster", "false"); + parameters.setAdditionalProperty("skipInit", "true"); + + // Test with empty prefix + SecretsManager.SecretsConfig secretsConfig = + new SecretsManager.SecretsConfig(CLUSTER_NAME, "", new ArrayList<>(), parameters); + + KubernetesSecretsManager secretsManagerWithEmptyPrefix = + KubernetesSecretsManager.getInstance(secretsConfig); + secretsManagerWithEmptyPrefix.setApiClient(mockApiClient); + secretsManagerWithEmptyPrefix.setNamespace(NAMESPACE); + + // Test empty string name - this should result in "om-secret-field" + // Input "" becomes "/field" after buildSecretId + // After sanitization: "field" (starts with 'f', so no prefix needed) + testSanitizedNameWithPrefix(secretsManagerWithEmptyPrefix, "", "field", "secret:/field"); + } + + @Test + void testEmptyStringNameWithCustomPrefix() throws Exception { + // Reset singleton instance + java.lang.reflect.Field instanceField = + KubernetesSecretsManager.class.getDeclaredField("instance"); + instanceField.setAccessible(true); + instanceField.set(null, null); + + Parameters parameters = new Parameters(); + parameters.setAdditionalProperty("namespace", NAMESPACE); + parameters.setAdditionalProperty("inCluster", "false"); + parameters.setAdditionalProperty("skipInit", "true"); + + // Test with custom prefix + String customPrefix = "myapp"; + SecretsManager.SecretsConfig secretsConfig = + new SecretsManager.SecretsConfig(CLUSTER_NAME, customPrefix, new ArrayList<>(), parameters); + + KubernetesSecretsManager secretsManagerWithCustomPrefix = + KubernetesSecretsManager.getInstance(secretsConfig); + secretsManagerWithCustomPrefix.setApiClient(mockApiClient); + secretsManagerWithCustomPrefix.setNamespace(NAMESPACE); + + // Test empty string name with custom prefix + // Input "" becomes "/field" after buildSecretId + // After sanitization: "field" (starts with 'f', so no prefix needed) + testSanitizedNameWithPrefix(secretsManagerWithCustomPrefix, "", "field", "secret:/field"); + } + + // Add tests that actually trigger prefix application + @Test + void testPrefixAppliedWhenNameStartsWithHyphen() throws Exception { + // Reset singleton instance + java.lang.reflect.Field instanceField = + KubernetesSecretsManager.class.getDeclaredField("instance"); + instanceField.setAccessible(true); + instanceField.set(null, null); + + Parameters parameters = new Parameters(); + parameters.setAdditionalProperty("namespace", NAMESPACE); + parameters.setAdditionalProperty("inCluster", "false"); + parameters.setAdditionalProperty("skipInit", "true"); + + // Test with custom prefix + String customPrefix = "myapp"; + SecretsManager.SecretsConfig secretsConfig = + new SecretsManager.SecretsConfig(CLUSTER_NAME, customPrefix, new ArrayList<>(), parameters); + + KubernetesSecretsManager secretsManagerWithCustomPrefix = + KubernetesSecretsManager.getInstance(secretsConfig); + secretsManagerWithCustomPrefix.setApiClient(mockApiClient); + secretsManagerWithCustomPrefix.setNamespace(NAMESPACE); + + // Test with a name that will actually trigger prefix application + // Input "-" becomes "-/field" after buildSecretId + // After sanitization: "field" (leading hyphen gets removed, so no prefix needed) + testSanitizedNameWithPrefix(secretsManagerWithCustomPrefix, "-", "field", "secret:-/field"); + } + + @Test + void testPrefixAppliedWhenNameStartsWithSpecialChar() throws Exception { + // Reset singleton instance + java.lang.reflect.Field instanceField = + KubernetesSecretsManager.class.getDeclaredField("instance"); + instanceField.setAccessible(true); + instanceField.set(null, null); + + Parameters parameters = new Parameters(); + parameters.setAdditionalProperty("namespace", NAMESPACE); + parameters.setAdditionalProperty("inCluster", "false"); + parameters.setAdditionalProperty("skipInit", "true"); + + // Test with custom prefix + String customPrefix = "myapp"; + SecretsManager.SecretsConfig secretsConfig = + new SecretsManager.SecretsConfig(CLUSTER_NAME, customPrefix, new ArrayList<>(), parameters); + + KubernetesSecretsManager secretsManagerWithCustomPrefix = + KubernetesSecretsManager.getInstance(secretsConfig); + secretsManagerWithCustomPrefix.setApiClient(mockApiClient); + secretsManagerWithCustomPrefix.setNamespace(NAMESPACE); + + // Test with a name that will actually trigger prefix application + // Input "@special" becomes "_special/field" after buildSecretId (special chars replaced with _) + // After sanitization: "special-field" (starts with 's', so no prefix needed) + testSanitizedNameWithPrefix( + secretsManagerWithCustomPrefix, "@special", "special-field", "secret:_special/field"); + } + + private V1Secret createMockSecret(String value) { + V1Secret secret = new V1Secret(); + V1ObjectMeta metadata = new V1ObjectMeta(); + metadata.setName(K8S_SECRET_NAME); + metadata.setNamespace(NAMESPACE); + secret.setMetadata(metadata); + + Map data = new HashMap<>(); + data.put("value", value.getBytes(StandardCharsets.UTF_8)); + secret.setData(data); + + return secret; + } + + private void testSanitizedName(String input, String expectedSanitizedName, String expectedResult) + throws ApiException { + reset(mockApiClient, readRequest, createRequest); + when(mockApiClient.readNamespacedSecret(anyString(), eq(NAMESPACE))).thenReturn(readRequest); + when(readRequest.execute()).thenThrow(new ApiException(404, "Not Found")); + ArgumentCaptor secretCaptor = ArgumentCaptor.forClass(V1Secret.class); + when(mockApiClient.createNamespacedSecret(eq(NAMESPACE), any(V1Secret.class))) + .thenReturn(createRequest); + when(createRequest.execute()).thenReturn(new V1Secret()); + String result = secretsManager.storeValue("field", "value", input, true); + verify(mockApiClient).createNamespacedSecret(eq(NAMESPACE), secretCaptor.capture()); + V1Secret createdSecret = secretCaptor.getValue(); + String actualName = Objects.requireNonNull(createdSecret.getMetadata()).getName(); + assertEquals(expectedSanitizedName, actualName); + assertEquals(expectedResult, result); + } + + private void testSanitizedNameWithPrefix( + KubernetesSecretsManager secretsManagerInstance, + String input, + String expectedSanitizedName, + String expectedResult) + throws ApiException { + reset(mockApiClient, readRequest, createRequest); + when(mockApiClient.readNamespacedSecret(anyString(), eq(NAMESPACE))).thenReturn(readRequest); + when(readRequest.execute()).thenThrow(new ApiException(404, "Not Found")); + ArgumentCaptor secretCaptor = ArgumentCaptor.forClass(V1Secret.class); + when(mockApiClient.createNamespacedSecret(eq(NAMESPACE), any(V1Secret.class))) + .thenReturn(createRequest); + when(createRequest.execute()).thenReturn(new V1Secret()); + String result = secretsManagerInstance.storeValue("field", "value", input, true); + verify(mockApiClient).createNamespacedSecret(eq(NAMESPACE), secretCaptor.capture()); + V1Secret createdSecret = secretCaptor.getValue(); + String actualName = Objects.requireNonNull(createdSecret.getMetadata()).getName(); + assertEquals(expectedSanitizedName, actualName); + assertEquals(expectedResult, result); + } + + @AfterEach + void tearDown() throws Exception { + // Reset singleton instance after each test + java.lang.reflect.Field instanceField = + KubernetesSecretsManager.class.getDeclaredField("instance"); + instanceField.setAccessible(true); + instanceField.set(null, null); + } +} diff --git a/openmetadata-spec/src/main/resources/json/schema/security/credentials/kubernetesCredentials.json b/openmetadata-spec/src/main/resources/json/schema/security/credentials/kubernetesCredentials.json new file mode 100644 index 00000000000..b1211a1af0a --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/security/credentials/kubernetesCredentials.json @@ -0,0 +1,27 @@ +{ + "$id": "https://open-metadata.org/schema/security/credentials/kubernetesCredentials.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "KubernetesCredentials", + "description": "Credentials for a Kubernetes cluster", + "type": "object", + "javaType": "org.openmetadata.schema.security.credentials.KubernetesCredentials", + "properties": { + "namespace": { + "title": "Namespace", + "description": "The namespace of the Kubernetes cluster", + "type": "string" + }, + "inCluster": { + "title": "In Cluster", + "description": "Whether the Kubernetes secrets manager is running in the same cluster where the OpenMetadata services are running", + "type": "boolean" + }, + "kubeconfigPath": { + "title": "Kubeconfig Path", + "description": "The path to the kubeconfig file", + "type": "string" + } + }, + "additionalProperties": false + } + \ No newline at end of file diff --git a/openmetadata-spec/src/main/resources/json/schema/security/secrets/secretsManagerProvider.json b/openmetadata-spec/src/main/resources/json/schema/security/secrets/secretsManagerProvider.json index f222f13db83..b333f373c2a 100644 --- a/openmetadata-spec/src/main/resources/json/schema/security/secrets/secretsManagerProvider.json +++ b/openmetadata-spec/src/main/resources/json/schema/security/secrets/secretsManagerProvider.json @@ -5,7 +5,7 @@ "description": "OpenMetadata Secrets Manager Provider. Make sure to configure the same secrets manager providers as the ones configured on the OpenMetadata server.", "type": "string", "javaType": "org.openmetadata.schema.security.secrets.SecretsManagerProvider", - "enum": ["db", "managed-aws","aws", "managed-aws-ssm", "aws-ssm", "managed-azure-kv", "azure-kv", "in-memory", "gcp"], + "enum": ["db", "managed-aws","aws", "managed-aws-ssm", "aws-ssm", "managed-azure-kv", "azure-kv", "in-memory", "gcp", "kubernetes"], "default": "db", "additionalProperties": false } diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/automations/createWorkflow.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/automations/createWorkflow.ts index 41e92506e77..e3d399e032b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/api/automations/createWorkflow.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/automations/createWorkflow.ts @@ -3691,6 +3691,7 @@ export enum SecretsManagerProvider { DB = "db", Gcp = "gcp", InMemory = "in-memory", + Kubernetes = "kubernetes", ManagedAws = "managed-aws", ManagedAwsSsm = "managed-aws-ssm", ManagedAzureKv = "managed-azure-kv", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createMetadataService.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createMetadataService.ts index 1d379f87e5c..2654854caaa 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createMetadataService.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createMetadataService.ts @@ -751,6 +751,7 @@ export enum SecretsManagerProvider { DB = "db", Gcp = "gcp", InMemory = "in-memory", + Kubernetes = "kubernetes", ManagedAws = "managed-aws", ManagedAwsSsm = "managed-aws-ssm", ManagedAzureKv = "managed-azure-kv", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/services/ingestionPipelines/createIngestionPipeline.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/services/ingestionPipelines/createIngestionPipeline.ts index 960b98c79fd..77a784119ab 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/api/services/ingestionPipelines/createIngestionPipeline.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/services/ingestionPipelines/createIngestionPipeline.ts @@ -5384,6 +5384,7 @@ export enum SecretsManagerProvider { DB = "db", Gcp = "gcp", InMemory = "in-memory", + Kubernetes = "kubernetes", ManagedAws = "managed-aws", ManagedAwsSsm = "managed-aws-ssm", ManagedAzureKv = "managed-azure-kv", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/applications/app.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/applications/app.ts index 4445118e5fc..c53dc4c0dc2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/applications/app.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/applications/app.ts @@ -1331,6 +1331,7 @@ export enum SecretsManagerProvider { DB = "db", Gcp = "gcp", InMemory = "in-memory", + Kubernetes = "kubernetes", ManagedAws = "managed-aws", ManagedAwsSsm = "managed-aws-ssm", ManagedAzureKv = "managed-azure-kv", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/automations/testServiceConnection.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/automations/testServiceConnection.ts index 426465302bb..80e621e8036 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/automations/testServiceConnection.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/automations/testServiceConnection.ts @@ -3573,6 +3573,7 @@ export enum SecretsManagerProvider { DB = "db", Gcp = "gcp", InMemory = "in-memory", + Kubernetes = "kubernetes", ManagedAws = "managed-aws", ManagedAwsSsm = "managed-aws-ssm", ManagedAzureKv = "managed-azure-kv", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/automations/workflow.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/automations/workflow.ts index 4383e1f1ca7..2360a7ea362 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/automations/workflow.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/automations/workflow.ts @@ -456,6 +456,7 @@ export enum SecretsManagerProvider { DB = "db", Gcp = "gcp", InMemory = "in-memory", + Kubernetes = "kubernetes", ManagedAws = "managed-aws", ManagedAwsSsm = "managed-aws-ssm", ManagedAzureKv = "managed-azure-kv", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/connections/metadata/openMetadataConnection.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/connections/metadata/openMetadataConnection.ts index 52c557e66dd..2aad4177d86 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/connections/metadata/openMetadataConnection.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/connections/metadata/openMetadataConnection.ts @@ -226,6 +226,7 @@ export enum SecretsManagerProvider { DB = "db", Gcp = "gcp", InMemory = "in-memory", + Kubernetes = "kubernetes", ManagedAws = "managed-aws", ManagedAwsSsm = "managed-aws-ssm", ManagedAzureKv = "managed-azure-kv", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/connections/serviceConnection.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/connections/serviceConnection.ts index 372b61e80fe..e5c9d01c170 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/connections/serviceConnection.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/connections/serviceConnection.ts @@ -3554,6 +3554,7 @@ export enum SecretsManagerProvider { DB = "db", Gcp = "gcp", InMemory = "in-memory", + Kubernetes = "kubernetes", ManagedAws = "managed-aws", ManagedAwsSsm = "managed-aws-ssm", ManagedAzureKv = "managed-azure-kv", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/ingestionPipelines/ingestionPipeline.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/ingestionPipelines/ingestionPipeline.ts index 6ea94b126a2..2f734ca84c8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/ingestionPipelines/ingestionPipeline.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/ingestionPipelines/ingestionPipeline.ts @@ -579,6 +579,7 @@ export enum SecretsManagerProvider { DB = "db", Gcp = "gcp", InMemory = "in-memory", + Kubernetes = "kubernetes", ManagedAws = "managed-aws", ManagedAwsSsm = "managed-aws-ssm", ManagedAzureKv = "managed-azure-kv", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/metadataService.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/metadataService.ts index 5fb865ff832..fb31bcc2592 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/metadataService.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/metadataService.ts @@ -871,6 +871,7 @@ export enum SecretsManagerProvider { DB = "db", Gcp = "gcp", InMemory = "in-memory", + Kubernetes = "kubernetes", ManagedAws = "managed-aws", ManagedAwsSsm = "managed-aws-ssm", ManagedAzureKv = "managed-azure-kv", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/metadataIngestion/application.ts b/openmetadata-ui/src/main/resources/ui/src/generated/metadataIngestion/application.ts index 08aed4b5c20..0fe4f735137 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/metadataIngestion/application.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/metadataIngestion/application.ts @@ -1164,6 +1164,7 @@ export enum SecretsManagerProvider { DB = "db", Gcp = "gcp", InMemory = "in-memory", + Kubernetes = "kubernetes", ManagedAws = "managed-aws", ManagedAwsSsm = "managed-aws-ssm", ManagedAzureKv = "managed-azure-kv", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/metadataIngestion/testSuitePipeline.ts b/openmetadata-ui/src/main/resources/ui/src/generated/metadataIngestion/testSuitePipeline.ts index 50921a72706..a752ef072f7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/metadataIngestion/testSuitePipeline.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/metadataIngestion/testSuitePipeline.ts @@ -3598,6 +3598,7 @@ export enum SecretsManagerProvider { DB = "db", Gcp = "gcp", InMemory = "in-memory", + Kubernetes = "kubernetes", ManagedAws = "managed-aws", ManagedAwsSsm = "managed-aws-ssm", ManagedAzureKv = "managed-azure-kv", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/metadataIngestion/workflow.ts b/openmetadata-ui/src/main/resources/ui/src/generated/metadataIngestion/workflow.ts index b9d7bd2ee13..ea890b2385d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/metadataIngestion/workflow.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/metadataIngestion/workflow.ts @@ -3647,6 +3647,7 @@ export enum SecretsManagerProvider { DB = "db", Gcp = "gcp", InMemory = "in-memory", + Kubernetes = "kubernetes", ManagedAws = "managed-aws", ManagedAwsSsm = "managed-aws-ssm", ManagedAzureKv = "managed-azure-kv", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/security/secrets/secretsManagerConfiguration.ts b/openmetadata-ui/src/main/resources/ui/src/generated/security/secrets/secretsManagerConfiguration.ts index 6df34ec07d9..9b3457392b2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/security/secrets/secretsManagerConfiguration.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/security/secrets/secretsManagerConfiguration.ts @@ -44,6 +44,7 @@ export enum SecretsManagerProvider { DB = "db", Gcp = "gcp", InMemory = "in-memory", + Kubernetes = "kubernetes", ManagedAws = "managed-aws", ManagedAwsSsm = "managed-aws-ssm", ManagedAzureKv = "managed-azure-kv", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/security/secrets/secretsManagerProvider.ts b/openmetadata-ui/src/main/resources/ui/src/generated/security/secrets/secretsManagerProvider.ts index eae7430be1b..2c1af935fb1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/security/secrets/secretsManagerProvider.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/security/secrets/secretsManagerProvider.ts @@ -21,6 +21,7 @@ export enum SecretsManagerProvider { DB = "db", Gcp = "gcp", InMemory = "in-memory", + Kubernetes = "kubernetes", ManagedAws = "managed-aws", ManagedAwsSsm = "managed-aws-ssm", ManagedAzureKv = "managed-azure-kv",