diff --git a/core/src/main/java/org/apache/seata/core/constants/DBType.java b/common/src/main/java/org/apache/seata/core/constants/DBType.java similarity index 100% rename from core/src/main/java/org/apache/seata/core/constants/DBType.java rename to common/src/main/java/org/apache/seata/core/constants/DBType.java diff --git a/console/pom.xml b/console/pom.xml index 594de337913..b849a858a97 100644 --- a/console/pom.xml +++ b/console/pom.xml @@ -32,6 +32,7 @@ console for Seata built with Maven + 25 3.5.2 6.2.8 2.0 @@ -168,6 +169,22 @@ spring-ai-starter-mcp-server-webmvc ${spring-ai.version} + + com.alibaba + druid + + + org.apache.commons + commons-dbcp2 + + + com.zaxxer + HikariCP + + + com.mysql + mysql-connector-j + diff --git a/console/src/main/java/org/apache/seata/console/controller/BusinessDataSourceController.java b/console/src/main/java/org/apache/seata/console/controller/BusinessDataSourceController.java new file mode 100644 index 00000000000..b3dd5048bf5 --- /dev/null +++ b/console/src/main/java/org/apache/seata/console/controller/BusinessDataSourceController.java @@ -0,0 +1,114 @@ +/* + * 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. + */ +package org.apache.seata.console.controller; + +import org.apache.seata.common.result.SingleResult; +import org.apache.seata.common.util.StringUtils; +import org.apache.seata.console.security.DataSourcePasswordCipher; +import org.apache.seata.console.security.DataSourcePasswordTransportCipher; +import org.apache.seata.mcp.entity.dto.MysqlDataSourceRegisterRequest; +import org.apache.seata.mcp.entity.vo.MysqlDataSourceInfo; +import org.apache.seata.mcp.entity.vo.MysqlDataSourceTestResult; +import org.apache.seata.mcp.service.BusinessDataSourceService; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/businessDataSources") +public class BusinessDataSourceController { + + private final BusinessDataSourceService dataSourceService; + + private final DataSourcePasswordCipher passwordCipher; + + private final DataSourcePasswordTransportCipher passwordTransportCipher; + + public BusinessDataSourceController( + BusinessDataSourceService dataSourceService, + DataSourcePasswordCipher passwordCipher, + DataSourcePasswordTransportCipher passwordTransportCipher) { + this.dataSourceService = dataSourceService; + this.passwordCipher = passwordCipher; + this.passwordTransportCipher = passwordTransportCipher; + } + + @GetMapping + public SingleResult> listDataSources() { + return SingleResult.success(dataSourceService.getMysqlDataSources()); + } + + @GetMapping("/password/publicKey") + public SingleResult getPasswordPublicKey() { + return SingleResult.success(passwordTransportCipher.getPublicKey()); + } + + @PostMapping + public SingleResult registerDataSource(@RequestBody MysqlDataSourceRegisterRequest request) { + try { + preparePassword(request); + return SingleResult.success(dataSourceService.registerMysqlDataSource(request)); + } catch (Exception e) { + return SingleResult.failure(e.getMessage()); + } + } + + @PostMapping("/test") + public SingleResult testDataSource(@RequestBody MysqlDataSourceRegisterRequest request) { + try { + preparePassword(request); + return SingleResult.success(dataSourceService.testMysqlDataSource(request)); + } catch (Exception e) { + MysqlDataSourceTestResult result = new MysqlDataSourceTestResult(); + result.setSuccess(false); + result.setMessage(e.getMessage()); + return SingleResult.success(result); + } + } + + @DeleteMapping("/{name}") + public SingleResult unregisterDataSource(@PathVariable String name) { + try { + return SingleResult.success(dataSourceService.unregisterMysqlDataSource(name)); + } catch (Exception e) { + return SingleResult.failure(e.getMessage()); + } + } + + private void preparePassword(MysqlDataSourceRegisterRequest request) { + if (request == null) { + return; + } + if (StringUtils.isBlank(request.getPassword())) { + return; + } + if (passwordTransportCipher.isEncrypted(request.getPassword())) { + request.setPassword(passwordTransportCipher.decrypt(request.getPassword())); + return; + } + if (!passwordCipher.isEnabled()) { + return; + } + request.setPassword(passwordCipher.decrypt(request.getPassword())); + } +} diff --git a/console/src/main/java/org/apache/seata/console/security/DataSourcePasswordCipher.java b/console/src/main/java/org/apache/seata/console/security/DataSourcePasswordCipher.java new file mode 100644 index 00000000000..af8c459a88c --- /dev/null +++ b/console/src/main/java/org/apache/seata/console/security/DataSourcePasswordCipher.java @@ -0,0 +1,108 @@ +/* + * 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. + */ +package org.apache.seata.console.security; + +import org.apache.seata.common.util.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Base64; + +@Component +public class DataSourcePasswordCipher { + + private static final String AES_ALGORITHM = "AES"; + + private static final String AES_GCM_TRANSFORMATION = "AES/GCM/NoPadding"; + + private static final int GCM_IV_LENGTH = 12; + + private static final int GCM_TAG_LENGTH_BITS = 128; + + private final SecretKeySpec secretKeySpec; + + private final boolean enabled; + + private final SecureRandom secureRandom = new SecureRandom(); + + public DataSourcePasswordCipher( + @Value("${seata.security.secretKey}") String secretKey, + @Value("${seata.businessDataSources.encryption.enabled:true}") boolean enabled) { + if (StringUtils.isBlank(secretKey)) { + throw new IllegalArgumentException("seata.security.secretKey cannot be empty"); + } + this.secretKeySpec = new SecretKeySpec(sha256(secretKey), AES_ALGORITHM); + this.enabled = enabled; + } + + public boolean isEnabled() { + return enabled; + } + + public String decrypt(String encryptedPassword) { + if (StringUtils.isBlank(encryptedPassword)) { + return ""; + } + try { + byte[] payload = Base64.getDecoder().decode(encryptedPassword); + if (payload.length <= GCM_IV_LENGTH) { + throw new IllegalArgumentException("Invalid datasource password ciphertext"); + } + byte[] iv = Arrays.copyOfRange(payload, 0, GCM_IV_LENGTH); + byte[] ciphertext = Arrays.copyOfRange(payload, GCM_IV_LENGTH, payload.length); + Cipher cipher = Cipher.getInstance(AES_GCM_TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)); + return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8); + } catch (Exception e) { + throw new IllegalArgumentException("Unable to decrypt datasource password"); + } + } + + public String encrypt(String password) { + if (StringUtils.isBlank(password)) { + return ""; + } + try { + byte[] iv = new byte[GCM_IV_LENGTH]; + secureRandom.nextBytes(iv); + Cipher cipher = Cipher.getInstance(AES_GCM_TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)); + byte[] ciphertext = cipher.doFinal(password.getBytes(StandardCharsets.UTF_8)); + byte[] payload = new byte[iv.length + ciphertext.length]; + System.arraycopy(iv, 0, payload, 0, iv.length); + System.arraycopy(ciphertext, 0, payload, iv.length, ciphertext.length); + return Base64.getEncoder().encodeToString(payload); + } catch (Exception e) { + throw new IllegalArgumentException("Unable to encrypt datasource password"); + } + } + + private byte[] sha256(String secretKey) { + try { + return MessageDigest.getInstance("SHA-256").digest(secretKey.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new IllegalArgumentException("Unable to initialize datasource password cipher"); + } + } +} diff --git a/console/src/main/java/org/apache/seata/console/security/DataSourcePasswordTransportCipher.java b/console/src/main/java/org/apache/seata/console/security/DataSourcePasswordTransportCipher.java new file mode 100644 index 00000000000..48dfacf322c --- /dev/null +++ b/console/src/main/java/org/apache/seata/console/security/DataSourcePasswordTransportCipher.java @@ -0,0 +1,99 @@ +/* + * 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. + */ +package org.apache.seata.console.security; + +import org.apache.seata.common.util.StringUtils; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import javax.crypto.spec.OAEPParameterSpec; +import javax.crypto.spec.PSource; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.spec.MGF1ParameterSpec; +import java.util.Base64; + +@Component +public class DataSourcePasswordTransportCipher { + + private static final String RSA_ALGORITHM = "RSA"; + + private static final String RSA_OAEP_TRANSFORMATION = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding"; + + private static final int RSA_KEY_SIZE = 2048; + + private static final String TRANSPORT_CIPHER_PREFIX = "rsa:"; + + private final KeyPair keyPair; + + public DataSourcePasswordTransportCipher() { + try { + KeyPairGenerator generator = KeyPairGenerator.getInstance(RSA_ALGORITHM); + generator.initialize(RSA_KEY_SIZE); + this.keyPair = generator.generateKeyPair(); + } catch (Exception e) { + throw new IllegalArgumentException("Unable to initialize datasource password transport cipher"); + } + } + + public String getPublicKey() { + return Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()); + } + + public boolean isEncrypted(String password) { + return StringUtils.isNotBlank(password) && password.startsWith(TRANSPORT_CIPHER_PREFIX); + } + + public String encrypt(String password) { + if (StringUtils.isBlank(password)) { + return ""; + } + try { + Cipher cipher = newCipher(Cipher.ENCRYPT_MODE); + byte[] ciphertext = cipher.doFinal(password.getBytes(StandardCharsets.UTF_8)); + return TRANSPORT_CIPHER_PREFIX + Base64.getEncoder().encodeToString(ciphertext); + } catch (Exception e) { + throw new IllegalArgumentException("Unable to encrypt datasource password for transport"); + } + } + + public String decrypt(String encryptedPassword) { + if (StringUtils.isBlank(encryptedPassword)) { + return ""; + } + if (!isEncrypted(encryptedPassword)) { + throw new IllegalArgumentException("Invalid datasource password transport ciphertext"); + } + try { + String ciphertext = encryptedPassword.substring(TRANSPORT_CIPHER_PREFIX.length()); + byte[] payload = Base64.getDecoder().decode(ciphertext); + Cipher cipher = newCipher(Cipher.DECRYPT_MODE); + return new String(cipher.doFinal(payload), StandardCharsets.UTF_8); + } catch (Exception e) { + throw new IllegalArgumentException("Unable to decrypt datasource password for transport"); + } + } + + private Cipher newCipher(int mode) throws Exception { + Cipher cipher = Cipher.getInstance(RSA_OAEP_TRANSFORMATION); + OAEPParameterSpec spec = + new OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT); + cipher.init(mode, mode == Cipher.ENCRYPT_MODE ? keyPair.getPublic() : keyPair.getPrivate(), spec); + return cipher; + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/core/constant/SqlConstant.java b/console/src/main/java/org/apache/seata/mcp/core/constant/SqlConstant.java new file mode 100644 index 00000000000..785f365befa --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/core/constant/SqlConstant.java @@ -0,0 +1,32 @@ +/* + * 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. + */ +package org.apache.seata.mcp.core.constant; + +public class SqlConstant { + + public static final String GET_TABLE_NAME_SQL = + "SELECT TABLE_NAME, TABLE_COMMENT FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? " + + "ORDER BY TABLE_NAME"; + + public static final String GET_SCHEMA_SQL = + "SELECT COLUMN_NAME, DATA_TYPE, COLUMN_COMMENT FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION"; + + public static final String MYSQL_VALIDATION_SQL = "SELECT 1"; + + public static final String MYSQL_EXPLAIN_PREFIX = "EXPLAIN "; +} diff --git a/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java b/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java new file mode 100644 index 00000000000..b5a1ddaf535 --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/core/props/BusinessDataSourcesProperties.java @@ -0,0 +1,640 @@ +/* + * 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. + */ +package org.apache.seata.mcp.core.props; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.seata.common.util.StringUtils; +import org.apache.seata.mcp.core.secret.SecretResolver; +import org.apache.seata.mcp.entity.dto.MysqlDataSourceRegisterRequest; +import org.apache.seata.mcp.entity.vo.MysqlDataSourceInfo; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.EnumerablePropertySource; +import org.springframework.core.env.Environment; +import org.springframework.core.env.PropertySource; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static org.apache.seata.common.DefaultValues.DEFAULT_DB_MAX_CONN; +import static org.apache.seata.common.DefaultValues.DEFAULT_DB_MIN_CONN; + +@Component +public class BusinessDataSourcesProperties implements InitializingBean { + + private final Environment env; + + @SuppressWarnings("unused") + private final ObjectMapper objectMapper; + + private final SecretResolver secretResolver; + + private final int maxDynamicDataSources; + + private final boolean dynamicRegistrationEnabled; + + private final Map allowedHosts; + + private static final Map DATASOURCES = new ConcurrentHashMap<>(); + + private static final Map DATA_SOURCES_NAMES_AND_RESOURCE_IDS = new ConcurrentHashMap<>(); + + private static final Set DYNAMIC_RESOURCE_IDS = ConcurrentHashMap.newKeySet(); + + private static final String BASE_PREFIX = "seata.businessDataSources."; + + private static final String MYSQL_DB_TYPE = "mysql"; + + private static final String MYSQL_DRIVER_CLASS_NAME = "com.mysql.cj.jdbc.Driver"; + + private static final String RESOURCE_ID_PREFIX = "business-ds://"; + + private static final int DEFAULT_MAX_DYNAMIC_DATA_SOURCES = 100; + + private static final Pattern DATASOURCE_NAME_PATTERN = Pattern.compile("[A-Za-z0-9_.-]+"); + + private static final Set SYSTEM_DATABASES = + new HashSet<>(Arrays.asList("information_schema", "mysql", "performance_schema", "sys")); + + private static final Logger LOGGER = LoggerFactory.getLogger(BusinessDataSourcesProperties.class); + + public BusinessDataSourcesProperties(Environment env, ObjectMapper objectMapper, SecretResolver secretResolver) { + this.env = env; + this.objectMapper = objectMapper; + this.secretResolver = secretResolver; + this.maxDynamicDataSources = env.getProperty( + "seata.businessDataSources.max-dynamic-size", Integer.class, DEFAULT_MAX_DYNAMIC_DATA_SOURCES); + this.dynamicRegistrationEnabled = + env.getProperty("seata.businessDataSources.dynamic-registration.enabled", Boolean.class, false); + this.allowedHosts = + parseAllowedHosts(env.getProperty("seata.businessDataSources.dynamic-registration.allowed-hosts", "")); + } + + @Override + public void afterPropertiesSet() { + Set dataSourceNames = getDataSourceNames(); + for (String name : dataSourceNames) { + DataSourceProperties props = new DataSourceProperties(); + String prefix = BASE_PREFIX + name + "."; + props.setName(name); + props.setResourceId(buildResourceId(name)); + props.setEnabled(env.getProperty(prefix + "enabled", Boolean.class, true)); + props.setDynamic(false); + props.setDbType(env.getProperty(prefix + "dbType", MYSQL_DB_TYPE)); + props.setDriverClassName(env.getProperty(prefix + "driverClassName", MYSQL_DRIVER_CLASS_NAME)); + props.setUrl(env.getProperty(prefix + "url")); + props.setUsername(env.getProperty(prefix + "username")); + props.setPassword(env.getProperty(prefix + "password")); + props.setPasswordSecretRef(env.getProperty(prefix + "passwordSecretRef")); + if (!StringUtils.hasText(props.getPassword()) && StringUtils.hasText(props.getPasswordSecretRef())) { + props.setPassword(secretResolver.resolve(props.getPasswordSecretRef())); + } + props.setDatasource(env.getProperty(prefix + "datasource", "druid")); + props.setMinConn(env.getProperty(prefix + "minConn", Integer.class, DEFAULT_DB_MIN_CONN)); + props.setMaxConn(env.getProperty(prefix + "maxConn", Integer.class, DEFAULT_DB_MAX_CONN)); + props.setMaxWait(env.getProperty(prefix + "maxWait", Long.class, 5000L)); + if (!validateDataSourceProperties(props, name)) { + continue; + } + if (props.enabled) { + DATASOURCES.put(props.getResourceId(), props); + DATA_SOURCES_NAMES_AND_RESOURCE_IDS.put(name, props.getResourceId()); + } + } + } + + public synchronized String registerMysqlDataSource(MysqlDataSourceRegisterRequest request) { + if (!dynamicRegistrationEnabled) { + throw new IllegalArgumentException("Dynamic business data source registration is disabled"); + } + DataSourceProperties props = buildDynamicMysqlProperties(request); + String name = props.getName(); + String resourceId = props.getResourceId(); + if (DATA_SOURCES_NAMES_AND_RESOURCE_IDS.containsKey(name) || DATASOURCES.containsKey(resourceId)) { + throw new IllegalArgumentException("The data source name has already been registered: " + name); + } + if (DYNAMIC_RESOURCE_IDS.size() >= maxDynamicDataSources) { + throw new IllegalArgumentException( + "The number of dynamic business data sources exceeds the limit: " + maxDynamicDataSources); + } + DATASOURCES.put(resourceId, props); + DATA_SOURCES_NAMES_AND_RESOURCE_IDS.put(name, resourceId); + DYNAMIC_RESOURCE_IDS.add(resourceId); + return resourceId; + } + + public synchronized DataSourceProperties buildDynamicMysqlProperties(MysqlDataSourceRegisterRequest request) { + if (request == null) { + throw new IllegalArgumentException("Data source registration request cannot be null"); + } + DataSourceProperties props = new DataSourceProperties(); + props.setName(request.getName()); + props.setResourceId(buildResourceId(request.getName())); + props.setEnabled(true); + props.setDynamic(true); + props.setDbType(MYSQL_DB_TYPE); + props.setDriverClassName(MYSQL_DRIVER_CLASS_NAME); + props.setUsername(request.getUsername()); + props.setPasswordSecretRef(request.getPasswordSecretRef()); + props.setPassword(resolvePassword(request)); + props.setDatasource(StringUtils.hasText(request.getDatasource()) ? request.getDatasource() : "druid"); + props.setMinConn(request.getMinConn() <= 0 ? DEFAULT_DB_MIN_CONN : request.getMinConn()); + props.setMaxConn(request.getMaxConn() <= 0 ? DEFAULT_DB_MAX_CONN : request.getMaxConn()); + props.setMaxWait(request.getMaxWait() == null ? 5000L : request.getMaxWait()); + validateDynamicMysqlProperties(props, request.getUrl()); + if (!validateDataSourceProperties(props, props.getName())) { + throw new IllegalArgumentException("Business DataSource Properties has failure"); + } + return props; + } + + public synchronized String unregisterMysqlDataSource(String name) { + if (!dynamicRegistrationEnabled) { + throw new IllegalArgumentException("Dynamic business data source registration is disabled"); + } + if (!StringUtils.hasText(name)) { + throw new IllegalArgumentException("The data source name cannot be empty"); + } + String resourceId = DATA_SOURCES_NAMES_AND_RESOURCE_IDS.get(name); + if (!StringUtils.hasText(resourceId) || !DYNAMIC_RESOURCE_IDS.contains(resourceId)) { + throw new IllegalArgumentException("Dynamic data source is not registered: " + name); + } + DATASOURCES.remove(resourceId); + DATA_SOURCES_NAMES_AND_RESOURCE_IDS.remove(name); + DYNAMIC_RESOURCE_IDS.remove(resourceId); + return resourceId; + } + + public List getMysqlDataSourceInfos() { + return DATA_SOURCES_NAMES_AND_RESOURCE_IDS.entrySet().stream() + .map(entry -> toInfo(entry.getKey(), DATASOURCES.get(entry.getValue()))) + .filter(info -> info != null) + .collect(Collectors.toList()); + } + + public String getDatabaseName(String resourceId) { + DataSourceProperties props = DATASOURCES.get(resourceId); + if (props == null) { + throw new IllegalArgumentException("Cannot find datasource properties: " + resourceId); + } + return props.getDatabaseName(); + } + + private MysqlDataSourceInfo toInfo(String name, DataSourceProperties props) { + if (props == null) { + return null; + } + MysqlDataSourceInfo info = new MysqlDataSourceInfo(); + info.setName(name); + info.setResourceId(props.getResourceId()); + info.setDatabaseName(props.getDatabaseName()); + info.setDatasource(props.getDatasource()); + info.setDynamic(props.isDynamic()); + info.setEnabled(props.isEnabled()); + return info; + } + + private String resolvePassword(MysqlDataSourceRegisterRequest request) { + if (StringUtils.hasText(request.getPassword())) { + return request.getPassword(); + } + return secretResolver.resolve(request.getPasswordSecretRef()); + } + + private boolean validateDataSourceProperties(DataSourceProperties props, String dataSourceName) { + if (props == null) { + LOGGER.error("DataSource configuration cannot be null for: {}", dataSourceName); + return false; + } + if (!StringUtils.hasText(props.getName())) { + LOGGER.error("DataSource name cannot be empty"); + return false; + } + if (!DATASOURCE_NAME_PATTERN.matcher(props.getName()).matches()) { + LOGGER.error("DataSource name contains unsupported characters"); + return false; + } + if (!StringUtils.hasText(props.getUrl())) { + LOGGER.error("Database URL cannot be empty for datasource: {}", dataSourceName); + return false; + } + if (!StringUtils.hasText(props.getUsername())) { + LOGGER.error("Database username cannot be empty for datasource: {}", dataSourceName); + return false; + } + if (!StringUtils.hasText(props.getPassword())) { + LOGGER.error("Database password cannot be empty for datasource: {}", dataSourceName); + return false; + } + if (!MYSQL_DB_TYPE.equalsIgnoreCase(props.getDbType())) { + LOGGER.error("Only MySQL business data source is supported: {}", dataSourceName); + return false; + } + if (!MYSQL_DRIVER_CLASS_NAME.equals(props.getDriverClassName())) { + LOGGER.error("Only MySQL 8 driver is supported for datasource: {}", dataSourceName); + return false; + } + try { + if (!StringUtils.hasText(props.getDatabaseName())) { + props.setDatabaseName(parseMysqlDatabaseName(props.getUrl())); + } + } catch (IllegalArgumentException e) { + LOGGER.error("Invalid MySQL JDBC URL for datasource: {}", dataSourceName); + return false; + } + if (props.getMinConn() < 0) { + LOGGER.error("Minimum connection count cannot be negative for datasource: {}", dataSourceName); + return false; + } + if (props.getMaxConn() <= 0) { + LOGGER.error("Maximum connection count must be positive for datasource: {}", dataSourceName); + return false; + } + if (props.getMinConn() > props.getMaxConn()) { + LOGGER.error( + "Minimum connection count cannot be greater than maximum connection count for datasource: {}", + dataSourceName); + return false; + } + if (props.getMaxWait() != null && props.getMaxWait() < 0) { + LOGGER.error("Maximum wait time cannot be negative for datasource: {}", dataSourceName); + return false; + } + return true; + } + + private void validateDynamicMysqlProperties(DataSourceProperties props, String url) { + if (StringUtils.isBlank(url) || !url.toLowerCase(Locale.ROOT).startsWith("jdbc:mysql://")) { + throw new IllegalArgumentException("Only jdbc:mysql:// URL is supported"); + } + if (allowedHosts.isEmpty()) { + throw new IllegalArgumentException("MySQL host allowlist cannot be empty for dynamic registration"); + } + MysqlJdbcUrl mysqlJdbcUrl = parseMysqlJdbcUrl(url); + MysqlAllowedHost allowedHost = allowedHosts.get(mysqlJdbcUrl.getNormalizedAddress()); + if (allowedHost == null) { + throw new IllegalArgumentException("MySQL host is not allowed: " + mysqlJdbcUrl.getHost()); + } + props.setDatabaseName(mysqlJdbcUrl.getDatabaseName()); + props.setUrl(allowedHost.toJdbcBaseUrl()); + } + + private String parseMysqlDatabaseName(String url) { + return parseMysqlJdbcUrl(url).getDatabaseName(); + } + + private MysqlJdbcUrl parseMysqlJdbcUrl(String url) { + if (!StringUtils.hasText(url) || !url.toLowerCase(Locale.ROOT).startsWith("jdbc:mysql://")) { + throw new IllegalArgumentException("Only jdbc:mysql:// URL is supported"); + } + try { + URI uri = URI.create(url.substring("jdbc:".length())); + if (StringUtils.hasText(uri.getUserInfo())) { + throw new IllegalArgumentException("MySQL JDBC URL cannot include user info"); + } + if (StringUtils.hasText(uri.getFragment())) { + throw new IllegalArgumentException("MySQL JDBC URL cannot include fragment"); + } + if (!StringUtils.hasText(uri.getHost())) { + throw new IllegalArgumentException("MySQL host cannot be empty"); + } + String path = uri.getPath(); + if (!StringUtils.hasText(path) || "/".equals(path)) { + throw new IllegalArgumentException("MySQL JDBC URL must include a database name"); + } + String databaseName = path.startsWith("/") ? path.substring(1) : path; + int slashIndex = databaseName.indexOf('/'); + if (slashIndex >= 0) { + databaseName = databaseName.substring(0, slashIndex); + } + if (!StringUtils.hasText(databaseName)) { + throw new IllegalArgumentException("MySQL JDBC URL must include a database name"); + } + String normalized = databaseName.toLowerCase(Locale.ROOT); + if (SYSTEM_DATABASES.contains(normalized)) { + throw new IllegalArgumentException("MySQL JDBC URL database is not allowed: " + databaseName); + } + int port = uri.getPort() < 0 ? 3306 : uri.getPort(); + if (port <= 0 || port > 65535) { + throw new IllegalArgumentException("MySQL port is invalid"); + } + return new MysqlJdbcUrl(uri.getHost(), port, databaseName); + } catch (IllegalArgumentException e) { + throw e; + } + } + + private Map parseAllowedHosts(String hosts) { + if (!StringUtils.hasText(hosts)) { + return Collections.emptyMap(); + } + return Arrays.stream(hosts.split(",")) + .map(String::trim) + .filter(StringUtils::isNotBlank) + .map(this::parseAllowedHost) + .collect(Collectors.toMap(MysqlAllowedHost::getNormalizedAddress, host -> host, (left, right) -> left)); + } + + private MysqlAllowedHost parseAllowedHost(String hostConfig) { + String config = hostConfig; + if (config.toLowerCase(Locale.ROOT).startsWith("jdbc:mysql://")) { + MysqlJdbcUrl jdbcUrl = parseMysqlJdbcUrl(config); + return new MysqlAllowedHost(jdbcUrl.getHost(), jdbcUrl.getPort()); + } + String host = config; + int port = 3306; + int portSeparator = config.lastIndexOf(':'); + if (portSeparator > 0 && portSeparator < config.length() - 1 && config.indexOf(']') < portSeparator) { + host = config.substring(0, portSeparator); + port = Integer.parseInt(config.substring(portSeparator + 1)); + } + if (host.startsWith("[") && host.endsWith("]")) { + host = host.substring(1, host.length() - 1); + } + if (!StringUtils.hasText(host) || port <= 0 || port > 65535) { + throw new IllegalArgumentException("Invalid MySQL host allowlist entry"); + } + return new MysqlAllowedHost(host, port); + } + + private String buildResourceId(String name) { + if (!StringUtils.hasText(name)) { + throw new IllegalArgumentException("The data source name cannot be empty"); + } + return RESOURCE_ID_PREFIX + name.trim(); + } + + private Set getDataSourceNames() { + Set names = new HashSet<>(); + if (env instanceof ConfigurableEnvironment) { + ConfigurableEnvironment configEnv = (ConfigurableEnvironment) env; + Set processedNames = new HashSet<>(); + for (PropertySource propertySource : configEnv.getPropertySources()) { + if (propertySource instanceof EnumerablePropertySource) { + EnumerablePropertySource enumSource = (EnumerablePropertySource) propertySource; + for (String propertyName : enumSource.getPropertyNames()) { + if (propertyName.startsWith(BASE_PREFIX)) { + String[] parts = propertyName.split("\\."); + if (parts.length > 3) { + String dsName = parts[2]; + if (!processedNames.contains(dsName) + && env.containsProperty(BASE_PREFIX + dsName + ".url")) { + names.add(dsName); + processedNames.add(dsName); + } + } + } + } + } + } + } + return names; + } + + public static Map getDatasources() { + return DATASOURCES; + } + + public static Map getDataSourcesNamesAndResourceIds() { + return DATA_SOURCES_NAMES_AND_RESOURCE_IDS; + } + + public static Set getResourceIds() { + return DATASOURCES.keySet(); + } + + public static Set getDynamicResourceIds() { + return DYNAMIC_RESOURCE_IDS; + } + + static void clear() { + DATASOURCES.clear(); + DATA_SOURCES_NAMES_AND_RESOURCE_IDS.clear(); + DYNAMIC_RESOURCE_IDS.clear(); + } + + private static class MysqlJdbcUrl { + private final String host; + private final int port; + private final String databaseName; + + MysqlJdbcUrl(String host, int port, String databaseName) { + this.host = host; + this.port = port; + this.databaseName = databaseName; + } + + String getHost() { + return host; + } + + int getPort() { + return port; + } + + String getNormalizedAddress() { + return normalizeAddress(host, port); + } + + String getDatabaseName() { + return databaseName; + } + } + + private static class MysqlAllowedHost { + private final String host; + private final int port; + + MysqlAllowedHost(String host, int port) { + this.host = host; + this.port = port; + } + + String getNormalizedAddress() { + return normalizeAddress(host, port); + } + + String toJdbcBaseUrl() { + StringBuilder builder = new StringBuilder("jdbc:mysql://") + .append(formatHost(host)) + .append(":") + .append(port); + return builder.toString(); + } + + private String formatHost(String host) { + if (host.indexOf(':') >= 0 && !host.startsWith("[") && !host.endsWith("]")) { + return "[" + host + "]"; + } + return host; + } + } + + private static String normalizeAddress(String host, int port) { + return host.toLowerCase(Locale.ROOT) + ":" + port; + } + + public static class DataSourceProperties { + private boolean enabled = true; + private boolean dynamic; + private String name; + private String resourceId; + private String databaseName; + private String dbType = MYSQL_DB_TYPE; + private String driverClassName = MYSQL_DRIVER_CLASS_NAME; + private String url = ""; + private String username = ""; + private String password = ""; + private String passwordSecretRef = ""; + private String datasource = "druid"; + private int minConn = DEFAULT_DB_MIN_CONN; + private int maxConn = DEFAULT_DB_MAX_CONN; + private Long maxWait = 5000L; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public boolean isDynamic() { + return dynamic; + } + + public void setDynamic(boolean dynamic) { + this.dynamic = dynamic; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getResourceId() { + return resourceId; + } + + public void setResourceId(String resourceId) { + this.resourceId = resourceId; + } + + public String getDatabaseName() { + return databaseName; + } + + public void setDatabaseName(String databaseName) { + this.databaseName = databaseName; + } + + public Long getMaxWait() { + return maxWait; + } + + public void setMaxWait(Long maxWait) { + this.maxWait = maxWait; + } + + public String getDbType() { + return dbType; + } + + public void setDbType(String dbType) { + this.dbType = dbType; + } + + public String getDriverClassName() { + return driverClassName; + } + + public void setDriverClassName(String driverClassName) { + this.driverClassName = driverClassName; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getPasswordSecretRef() { + return passwordSecretRef; + } + + public void setPasswordSecretRef(String passwordSecretRef) { + this.passwordSecretRef = passwordSecretRef; + } + + public String getDatasource() { + return datasource; + } + + public void setDatasource(String datasource) { + this.datasource = datasource; + } + + public int getMinConn() { + return minConn; + } + + public void setMinConn(int minConn) { + this.minConn = minConn; + } + + public int getMaxConn() { + return maxConn; + } + + public void setMaxConn(int maxConn) { + this.maxConn = maxConn; + } + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/core/secret/EnvSecretResolver.java b/console/src/main/java/org/apache/seata/mcp/core/secret/EnvSecretResolver.java new file mode 100644 index 00000000000..64e374b1362 --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/core/secret/EnvSecretResolver.java @@ -0,0 +1,46 @@ +/* + * 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. + */ +package org.apache.seata.mcp.core.secret; + +import org.apache.seata.common.util.StringUtils; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +@Component +public class EnvSecretResolver implements SecretResolver { + + private final Environment environment; + + public EnvSecretResolver(Environment environment) { + this.environment = environment; + } + + @Override + public String resolve(String secretRef) { + if (!StringUtils.hasText(secretRef)) { + throw new IllegalArgumentException("passwordSecretRef cannot be empty"); + } + String secret = environment.getProperty(secretRef); + if (!StringUtils.hasText(secret)) { + secret = System.getenv(secretRef); + } + if (!StringUtils.hasText(secret)) { + throw new IllegalArgumentException("Unable to resolve password secret: " + secretRef); + } + return secret; + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/core/secret/SecretResolver.java b/console/src/main/java/org/apache/seata/mcp/core/secret/SecretResolver.java new file mode 100644 index 00000000000..e4b9fa38bfc --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/core/secret/SecretResolver.java @@ -0,0 +1,22 @@ +/* + * 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. + */ +package org.apache.seata.mcp.core.secret; + +public interface SecretResolver { + + String resolve(String secretRef); +} diff --git a/console/src/main/java/org/apache/seata/mcp/core/utils/DateUtils.java b/console/src/main/java/org/apache/seata/mcp/core/utils/DateUtils.java index 237ec494e8f..f19e5471f53 100644 --- a/console/src/main/java/org/apache/seata/mcp/core/utils/DateUtils.java +++ b/console/src/main/java/org/apache/seata/mcp/core/utils/DateUtils.java @@ -70,7 +70,9 @@ public static String convertToDateTimeFromTimestamp(Long timestamp) { } public static boolean judgeExceedTimeDuration(Long startTime, Long endTime, Long maxDuration) { - if (endTime < startTime) throw new IllegalArgumentException("endTime must not be earlier than startTime"); + if (endTime < startTime) { + throw new IllegalArgumentException("endTime must not be earlier than startTime"); + } return endTime - startTime > maxDuration; } diff --git a/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java b/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java new file mode 100644 index 00000000000..901daf7116a --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/entity/dto/MysqlDataSourceRegisterRequest.java @@ -0,0 +1,107 @@ +/* + * 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. + */ +package org.apache.seata.mcp.entity.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class MysqlDataSourceRegisterRequest { + + private String name; + private String url; + private String username; + + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + private String password; + + private String passwordSecretRef; + private String datasource = "druid"; + private int minConn = 10; + private int maxConn = 100; + private Long maxWait = 5000L; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getPasswordSecretRef() { + return passwordSecretRef; + } + + public void setPasswordSecretRef(String passwordSecretRef) { + this.passwordSecretRef = passwordSecretRef; + } + + public String getDatasource() { + return datasource; + } + + public void setDatasource(String datasource) { + this.datasource = datasource; + } + + public int getMinConn() { + return minConn; + } + + public void setMinConn(int minConn) { + this.minConn = minConn; + } + + public int getMaxConn() { + return maxConn; + } + + public void setMaxConn(int maxConn) { + this.maxConn = maxConn; + } + + public Long getMaxWait() { + return maxWait; + } + + public void setMaxWait(Long maxWait) { + this.maxWait = maxWait; + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/entity/vo/BusinessQueryResult.java b/console/src/main/java/org/apache/seata/mcp/entity/vo/BusinessQueryResult.java new file mode 100644 index 00000000000..d18484e69e7 --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/entity/vo/BusinessQueryResult.java @@ -0,0 +1,88 @@ +/* + * 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. + */ +package org.apache.seata.mcp.entity.vo; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class BusinessQueryResult { + + private List columns = new ArrayList<>(); + private List> rows = new ArrayList<>(); + private int rowCount; + private boolean truncated; + private int maxRows; + private long executionTimeMs; + private String resourceId; + + public List getColumns() { + return columns; + } + + public void setColumns(List columns) { + this.columns = columns; + } + + public List> getRows() { + return rows; + } + + public void setRows(List> rows) { + this.rows = rows; + } + + public int getRowCount() { + return rowCount; + } + + public void setRowCount(int rowCount) { + this.rowCount = rowCount; + } + + public boolean isTruncated() { + return truncated; + } + + public void setTruncated(boolean truncated) { + this.truncated = truncated; + } + + public int getMaxRows() { + return maxRows; + } + + public void setMaxRows(int maxRows) { + this.maxRows = maxRows; + } + + public long getExecutionTimeMs() { + return executionTimeMs; + } + + public void setExecutionTimeMs(long executionTimeMs) { + this.executionTimeMs = executionTimeMs; + } + + public String getResourceId() { + return resourceId; + } + + public void setResourceId(String resourceId) { + this.resourceId = resourceId; + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlColumnInfo.java b/console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlColumnInfo.java new file mode 100644 index 00000000000..cd1ef75477e --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlColumnInfo.java @@ -0,0 +1,48 @@ +/* + * 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. + */ +package org.apache.seata.mcp.entity.vo; + +public class MysqlColumnInfo { + + private String columnName; + private String dataType; + private String columnComment; + + public String getColumnName() { + return columnName; + } + + public void setColumnName(String columnName) { + this.columnName = columnName; + } + + public String getDataType() { + return dataType; + } + + public void setDataType(String dataType) { + this.dataType = dataType; + } + + public String getColumnComment() { + return columnComment; + } + + public void setColumnComment(String columnComment) { + this.columnComment = columnComment; + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlDataSourceInfo.java b/console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlDataSourceInfo.java new file mode 100644 index 00000000000..16ca4dcabc3 --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlDataSourceInfo.java @@ -0,0 +1,75 @@ +/* + * 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. + */ +package org.apache.seata.mcp.entity.vo; + +public class MysqlDataSourceInfo { + + private String name; + private String resourceId; + private String databaseName; + private String datasource; + private boolean dynamic; + private boolean enabled; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getResourceId() { + return resourceId; + } + + public void setResourceId(String resourceId) { + this.resourceId = resourceId; + } + + public String getDatabaseName() { + return databaseName; + } + + public void setDatabaseName(String databaseName) { + this.databaseName = databaseName; + } + + public String getDatasource() { + return datasource; + } + + public void setDatasource(String datasource) { + this.datasource = datasource; + } + + public boolean isDynamic() { + return dynamic; + } + + public void setDynamic(boolean dynamic) { + this.dynamic = dynamic; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlDataSourceTestResult.java b/console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlDataSourceTestResult.java new file mode 100644 index 00000000000..2bc4a37eb23 --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlDataSourceTestResult.java @@ -0,0 +1,57 @@ +/* + * 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. + */ +package org.apache.seata.mcp.entity.vo; + +public class MysqlDataSourceTestResult { + + private boolean success; + private String message; + private String validationQuery; + private long elapsedMs; + + public boolean isSuccess() { + return success; + } + + public void setSuccess(boolean success) { + this.success = success; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getValidationQuery() { + return validationQuery; + } + + public void setValidationQuery(String validationQuery) { + this.validationQuery = validationQuery; + } + + public long getElapsedMs() { + return elapsedMs; + } + + public void setElapsedMs(long elapsedMs) { + this.elapsedMs = elapsedMs; + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlTableInfo.java b/console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlTableInfo.java new file mode 100644 index 00000000000..78d45735ad3 --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/entity/vo/MysqlTableInfo.java @@ -0,0 +1,39 @@ +/* + * 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. + */ +package org.apache.seata.mcp.entity.vo; + +public class MysqlTableInfo { + + private String tableName; + private String tableComment; + + public String getTableName() { + return tableName; + } + + public void setTableName(String tableName) { + this.tableName = tableName; + } + + public String getTableComment() { + return tableComment; + } + + public void setTableComment(String tableComment) { + this.tableComment = tableComment; + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/service/BusinessDataSourceService.java b/console/src/main/java/org/apache/seata/mcp/service/BusinessDataSourceService.java new file mode 100644 index 00000000000..03451d61267 --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/service/BusinessDataSourceService.java @@ -0,0 +1,48 @@ +/* + * 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. + */ +package org.apache.seata.mcp.service; + +import org.apache.seata.mcp.entity.dto.MysqlDataSourceRegisterRequest; +import org.apache.seata.mcp.entity.vo.BusinessQueryResult; +import org.apache.seata.mcp.entity.vo.MysqlColumnInfo; +import org.apache.seata.mcp.entity.vo.MysqlDataSourceInfo; +import org.apache.seata.mcp.entity.vo.MysqlDataSourceTestResult; +import org.apache.seata.mcp.entity.vo.MysqlTableInfo; + +import java.util.List; +import java.util.Map; + +public interface BusinessDataSourceService { + List getMysqlDataSources(); + + String registerMysqlDataSource(MysqlDataSourceRegisterRequest request); + + String unregisterMysqlDataSource(String name); + + MysqlDataSourceTestResult testMysqlDataSource(MysqlDataSourceRegisterRequest request); + + List getMysqlTableNames(String resourceId); + + List getMysqlTableSchema(String resourceId, String tableName); + + BusinessQueryResult runSql(String sql, String resourceId); + + BusinessQueryResult queryMysqlTable( + String resourceId, String tableName, List columns, Map filters, Integer limit); + + BusinessQueryResult explainMysqlSql(String resourceId, String sql); +} diff --git a/console/src/main/java/org/apache/seata/mcp/service/MysqlMetadataService.java b/console/src/main/java/org/apache/seata/mcp/service/MysqlMetadataService.java new file mode 100644 index 00000000000..b16d0d92344 --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/service/MysqlMetadataService.java @@ -0,0 +1,96 @@ +/* + * 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. + */ +package org.apache.seata.mcp.service; + +import org.apache.seata.common.exception.StoreException; +import org.apache.seata.common.util.StringUtils; +import org.apache.seata.mcp.core.constant.SqlConstant; +import org.apache.seata.mcp.core.props.BusinessDataSourcesProperties; +import org.apache.seata.mcp.entity.vo.BusinessQueryResult; +import org.apache.seata.mcp.entity.vo.MysqlColumnInfo; +import org.apache.seata.mcp.entity.vo.MysqlTableInfo; +import org.apache.seata.mcp.store.SqlExecutionTemplate; +import org.apache.seata.mcp.store.SqlSafetyValidator; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class MysqlMetadataService { + + private final SqlExecutionTemplate sqlExecutionTemplate; + + private final SqlSafetyValidator sqlSafetyValidator; + + private final BusinessDataSourcesProperties businessDataSourcesProperties; + + public MysqlMetadataService( + SqlExecutionTemplate sqlExecutionTemplate, + SqlSafetyValidator sqlSafetyValidator, + BusinessDataSourcesProperties businessDataSourcesProperties) { + this.sqlExecutionTemplate = sqlExecutionTemplate; + this.sqlSafetyValidator = sqlSafetyValidator; + this.businessDataSourcesProperties = businessDataSourcesProperties; + } + + public List listTables(String resourceId) { + String databaseName = businessDataSourcesProperties.getDatabaseName(resourceId); + return sqlExecutionTemplate + .trustedQuery(resourceId, SqlConstant.GET_TABLE_NAME_SQL, databaseName) + .getRows() + .stream() + .map(this::toTableInfo) + .collect(Collectors.toList()); + } + + public List describeTable(String resourceId, String tableName) { + String databaseName = businessDataSourcesProperties.getDatabaseName(resourceId); + if (!StringUtils.hasText(tableName)) { + throw new StoreException("tableName cannot be empty"); + } + return sqlExecutionTemplate + .trustedQuery(resourceId, SqlConstant.GET_SCHEMA_SQL, databaseName, tableName) + .getRows() + .stream() + .map(this::toColumnInfo) + .collect(Collectors.toList()); + } + + public BusinessQueryResult explainSql(String resourceId, String sql) { + sqlSafetyValidator.validateMysqlSelect(sql, businessDataSourcesProperties.getDatabaseName(resourceId)); + return sqlExecutionTemplate.trustedQuery(resourceId, SqlConstant.MYSQL_EXPLAIN_PREFIX + sql); + } + + private MysqlTableInfo toTableInfo(Map row) { + MysqlTableInfo info = new MysqlTableInfo(); + info.setTableName(String.valueOf(row.get("TABLE_NAME"))); + Object comment = row.get("TABLE_COMMENT"); + info.setTableComment(comment == null ? "" : String.valueOf(comment)); + return info; + } + + private MysqlColumnInfo toColumnInfo(Map row) { + MysqlColumnInfo info = new MysqlColumnInfo(); + info.setColumnName(String.valueOf(row.get("COLUMN_NAME"))); + info.setDataType(String.valueOf(row.get("DATA_TYPE"))); + Object comment = row.get("COLUMN_COMMENT"); + info.setColumnComment(comment == null ? "" : String.valueOf(comment)); + return info; + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java b/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java new file mode 100644 index 00000000000..15531c54e4b --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImpl.java @@ -0,0 +1,248 @@ +/* + * 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. + */ +package org.apache.seata.mcp.service.impl; + +import org.apache.seata.common.exception.StoreException; +import org.apache.seata.common.util.StringUtils; +import org.apache.seata.mcp.core.constant.SqlConstant; +import org.apache.seata.mcp.core.props.BusinessDataSourcesProperties; +import org.apache.seata.mcp.entity.dto.MysqlDataSourceRegisterRequest; +import org.apache.seata.mcp.entity.vo.BusinessQueryResult; +import org.apache.seata.mcp.entity.vo.MysqlColumnInfo; +import org.apache.seata.mcp.entity.vo.MysqlDataSourceInfo; +import org.apache.seata.mcp.entity.vo.MysqlDataSourceTestResult; +import org.apache.seata.mcp.entity.vo.MysqlTableInfo; +import org.apache.seata.mcp.service.BusinessDataSourceService; +import org.apache.seata.mcp.service.MysqlMetadataService; +import org.apache.seata.mcp.store.DataSourceFactory; +import org.apache.seata.mcp.store.SqlExecutionTemplate; +import org.apache.seata.mcp.store.SqlSafetyValidator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +@Service +public class BusinessDataSourceServiceImpl implements BusinessDataSourceService { + + private static final Pattern IDENTIFIER_PATTERN = Pattern.compile("[A-Za-z0-9_$]+"); + + private static final Logger LOGGER = LoggerFactory.getLogger(BusinessDataSourceServiceImpl.class); + + private final SqlExecutionTemplate sqlExecutionTemplate; + + private final BusinessDataSourcesProperties businessDataSourcesProperties; + + private final MysqlMetadataService mysqlMetadataService; + + private final SqlSafetyValidator sqlSafetyValidator; + + public BusinessDataSourceServiceImpl( + SqlExecutionTemplate sqlExecutionTemplate, + BusinessDataSourcesProperties businessDataSourcesProperties, + MysqlMetadataService mysqlMetadataService, + SqlSafetyValidator sqlSafetyValidator) { + this.sqlExecutionTemplate = sqlExecutionTemplate; + this.businessDataSourcesProperties = businessDataSourcesProperties; + this.mysqlMetadataService = mysqlMetadataService; + this.sqlSafetyValidator = sqlSafetyValidator; + } + + @Override + public List getMysqlDataSources() { + return businessDataSourcesProperties.getMysqlDataSourceInfos(); + } + + @Override + public String registerMysqlDataSource(MysqlDataSourceRegisterRequest request) { + return businessDataSourcesProperties.registerMysqlDataSource(request); + } + + @Override + public String unregisterMysqlDataSource(String name) { + String resourceId = businessDataSourcesProperties.unregisterMysqlDataSource(name); + DataSourceFactory.removeDataSource(resourceId); + return resourceId; + } + + @Override + public MysqlDataSourceTestResult testMysqlDataSource(MysqlDataSourceRegisterRequest request) { + long start = System.currentTimeMillis(); + MysqlDataSourceTestResult result = new MysqlDataSourceTestResult(); + result.setValidationQuery(SqlConstant.MYSQL_VALIDATION_SQL); + try { + BusinessDataSourcesProperties.DataSourceProperties props = + businessDataSourcesProperties.buildDynamicMysqlProperties(request); + Class.forName(props.getDriverClassName()); + try (Connection connection = + DriverManager.getConnection(props.getUrl(), props.getUsername(), props.getPassword())) { + connection.setCatalog(props.getDatabaseName()); + try (PreparedStatement statement = connection.prepareStatement(SqlConstant.MYSQL_VALIDATION_SQL)) { + statement.setQueryTimeout(5); + statement.executeQuery(); + } + } + result.setSuccess(true); + result.setMessage("OK"); + } catch (IllegalArgumentException e) { + LOGGER.warn("Business datasource connection test validation failed: {}", e.getMessage()); + result.setSuccess(false); + result.setMessage(e.getMessage()); + } catch (SQLException e) { + LOGGER.warn("Business datasource connection test failed: {}", sanitizeConnectionTestError(e, request)); + result.setSuccess(false); + result.setMessage(sanitizeConnectionTestError(e, request)); + } catch (Exception e) { + LOGGER.warn("Business datasource connection test failed: {}", sanitizeConnectionTestError(e, request)); + result.setSuccess(false); + result.setMessage(sanitizeConnectionTestError(e, request)); + } + result.setElapsedMs(System.currentTimeMillis() - start); + return result; + } + + @Override + public List getMysqlTableNames(String resourceId) { + return mysqlMetadataService.listTables(resourceId); + } + + @Override + public List getMysqlTableSchema(String resourceId, String tableName) { + return mysqlMetadataService.describeTable(resourceId, tableName); + } + + @Override + public BusinessQueryResult runSql(String sql, String resourceId) { + sqlSafetyValidator.validateMysqlSelect(sql, businessDataSourcesProperties.getDatabaseName(resourceId)); + return sqlExecutionTemplate.query(resourceId, sql); + } + + @Override + public BusinessQueryResult queryMysqlTable( + String resourceId, String tableName, List columns, Map filters, Integer limit) { + String databaseName = businessDataSourcesProperties.getDatabaseName(resourceId); + validateIdentifier("databaseName", databaseName); + validateIdentifier("tableName", tableName); + + StringBuilder sql = new StringBuilder("SELECT "); + if (columns == null || columns.isEmpty()) { + sql.append("*"); + } else { + sql.append(buildColumnList(columns)); + } + sql.append(" FROM ").append(quote(databaseName)).append(".").append(quote(tableName)); + + List params = new ArrayList<>(); + if (filters != null && !filters.isEmpty()) { + sql.append(" WHERE "); + boolean first = true; + for (Map.Entry entry : filters.entrySet()) { + validateIdentifier("filter column", entry.getKey()); + if (!first) { + sql.append(" AND "); + } + sql.append(quote(entry.getKey())).append(" = ?"); + params.add(entry.getValue()); + first = false; + } + } + + int maxRows = limit == null || limit <= 0 ? Integer.MAX_VALUE : limit; + return sqlExecutionTemplate.queryWithMaxRows(resourceId, sql.toString(), maxRows, params.toArray()); + } + + @Override + public BusinessQueryResult explainMysqlSql(String resourceId, String sql) { + return mysqlMetadataService.explainSql(resourceId, sql); + } + + private String buildColumnList(List columns) { + StringBuilder builder = new StringBuilder(); + for (String column : columns) { + validateIdentifier("column", column); + if (builder.length() > 0) { + builder.append(", "); + } + builder.append(quote(column)); + } + return builder.toString(); + } + + private void validateIdentifier(String field, String value) { + if (!StringUtils.hasText(value) || !IDENTIFIER_PATTERN.matcher(value).matches()) { + throw new StoreException(field + " contains unsupported characters"); + } + } + + private String quote(String identifier) { + return "`" + identifier + "`"; + } + + private String sanitizeConnectionTestError(Exception exception, MysqlDataSourceRegisterRequest request) { + StringBuilder message = new StringBuilder("Connection test failed"); + if (exception instanceof SQLException) { + SQLException sqlException = (SQLException) exception; + if (StringUtils.isNotBlank(sqlException.getSQLState())) { + message.append(" [SQLState: ") + .append(sqlException.getSQLState()) + .append("]"); + } + if (sqlException.getErrorCode() != 0) { + message.append(" [ErrorCode: ") + .append(sqlException.getErrorCode()) + .append("]"); + } + } else { + message.append(" [").append(exception.getClass().getSimpleName()).append("]"); + } + String detail = sanitizeSensitiveText(exception.getMessage(), request); + if (StringUtils.isNotBlank(detail)) { + message.append(": ").append(detail); + } + return message.toString(); + } + + private String sanitizeSensitiveText(String text, MysqlDataSourceRegisterRequest request) { + if (StringUtils.isBlank(text)) { + return ""; + } + String sanitized = text.replaceAll("jdbc:mysql://[^\\s,;]+", "jdbc:mysql://***"); + sanitized = sanitized.replaceAll("(?i)(password\\s*[=:]\\s*)[^\\s,;]+", "$1***"); + sanitized = sanitized.replaceAll("(?i)(user(name)?\\s*[=:]\\s*)[^\\s,;]+", "$1***"); + if (request != null) { + sanitized = replaceSensitiveValue(sanitized, request.getUrl()); + sanitized = replaceSensitiveValue(sanitized, request.getUsername()); + sanitized = replaceSensitiveValue(sanitized, request.getPassword()); + } + return sanitized; + } + + private String replaceSensitiveValue(String text, String sensitiveValue) { + if (StringUtils.isBlank(text) || StringUtils.isBlank(sensitiveValue)) { + return text; + } + return text.replace(sensitiveValue, "***"); + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/store/DataSourceFactory.java b/console/src/main/java/org/apache/seata/mcp/store/DataSourceFactory.java new file mode 100644 index 00000000000..848812ef7b6 --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/store/DataSourceFactory.java @@ -0,0 +1,109 @@ +/* + * 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. + */ +package org.apache.seata.mcp.store; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import org.apache.seata.common.exception.StoreException; +import org.apache.seata.mcp.core.props.BusinessDataSourcesProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class DataSourceFactory { + + private static final Map DATA_SOURCE_MAP = new ConcurrentHashMap<>(); + + private static final Logger LOGGER = LoggerFactory.getLogger(DataSourceFactory.class); + + @PostConstruct + public void init() { + DataSourceFactory.initAllDataSources(); + } + + @PreDestroy + public void destroy() { + DATA_SOURCE_MAP.forEach(DataSourceFactory::closeDataSource); + DATA_SOURCE_MAP.clear(); + } + + public static void initAllDataSources() { + Map datasources = + BusinessDataSourcesProperties.getDatasources(); + datasources.forEach((resourceId, props) -> + DATA_SOURCE_MAP.computeIfAbsent(resourceId, key -> createDataSource(props, key))); + } + + public static DataSource getDataSource(String resourceId) { + return DATA_SOURCE_MAP.computeIfAbsent(resourceId, key -> { + BusinessDataSourcesProperties.DataSourceProperties props = + BusinessDataSourcesProperties.getDatasources().get(key); + if (props == null) { + throw new StoreException("Cannot find datasource properties: " + key); + } + return createDataSource(props, key); + }); + } + + public static void removeErrorDataSource(String resourceId, Exception e) { + closeDataSource(resourceId, DATA_SOURCE_MAP.remove(resourceId)); + LOGGER.info("Delete Business DataSource, resourceId: {}", resourceId); + throw new StoreException("The Business DataSource: " + resourceId + " can't be connected"); + } + + public static void removeDataSource(String resourceId) { + closeDataSource(resourceId, DATA_SOURCE_MAP.remove(resourceId)); + LOGGER.info("Delete Business DataSource, resourceId: {}", resourceId); + } + + private static void closeDataSource(String resourceId, DataSource dataSource) { + if (dataSource instanceof AutoCloseable) { + try { + ((AutoCloseable) dataSource).close(); + } catch (Exception e) { + LOGGER.warn("Close Business DataSource failed, resourceId: {}", resourceId, e); + } + } + } + + public static DataSource createDataSource( + BusinessDataSourcesProperties.DataSourceProperties dataSourceProperties, String resourceId) { + if (dataSourceProperties == null) { + throw new StoreException("Cannot find datasource properties:" + resourceId); + } + + String type = dataSourceProperties.getDatasource(); + switch (type) { + case "druid": + DruidDataSourceProvider druidDataSourceProvider = new DruidDataSourceProvider(); + return druidDataSourceProvider.generateByResourceId(resourceId); + case "hikari": + HikariDataSourceProvider hikariDataSourceProvider = new HikariDataSourceProvider(); + return hikariDataSourceProvider.generateByResourceId(resourceId); + case "dbcp": + DbcpDataSourceProvider dbcpDataSourceProvider = new DbcpDataSourceProvider(); + return dbcpDataSourceProvider.generateByResourceId(resourceId); + default: + throw new StoreException("Unknown datasource type:" + type); + } + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/store/DbcpDataSourceProvider.java b/console/src/main/java/org/apache/seata/mcp/store/DbcpDataSourceProvider.java new file mode 100644 index 00000000000..a7a21779b3d --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/store/DbcpDataSourceProvider.java @@ -0,0 +1,67 @@ +/* + * 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. + */ +package org.apache.seata.mcp.store; + +import org.apache.commons.dbcp2.BasicDataSource; +import org.apache.seata.mcp.store.db.AbstractMCPDataSourceProvider; + +import javax.sql.DataSource; +import java.sql.Connection; + +/** + * The dbcp datasource provider + */ +public class DbcpDataSourceProvider extends AbstractMCPDataSourceProvider { + + @Override + public DataSource doGenerate() { + BasicDataSource ds = new BasicDataSource(); + ds.setDriverClassName(getDriverClassName()); + ds.setDriverClassLoader(getDriverClassLoader()); + ds.setUrl(getUrl()); + ds.setUsername(getUser()); + ds.setPassword(getPassword()); + ds.setDefaultCatalog(getDatabaseName()); + ds.setInitialSize(getMinConn()); + ds.setMaxTotal(getMaxConn()); + ds.setMinIdle(getMinConn()); + ds.setMaxIdle(getMinConn()); + ds.setMaxWaitMillis(getMaxWait()); + + ds.setMaxConnLifetimeMillis(300000); // Maximum connection lifetime (5 minutes) + ds.setLogExpiredConnections(true); // Log expired connections + ds.setConnectionProperties( + "useUnicode=yes;characterEncoding=utf8;socketTimeout=5000;connectTimeout=500;autoReconnect=true;maxReconnects=3;retriesAllDown=3"); + + ds.setTestOnCreate(true); // Validate connection on creation + ds.setTestOnBorrow(true); // Validate connection on borrow + ds.setTestWhileIdle(true); // Validate idle connections + ds.setValidationQuery(getValidationQuery(getDBType())); + ds.setValidationQueryTimeout(5); // Validation query timeout (seconds) + + ds.setFastFailValidation(true); // Fast validation failure + ds.setDefaultTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); + + ds.setMaxWaitMillis(5000); // Maximum wait time of 5 seconds + ds.setAbandonedUsageTracking(true); // Track connection usage + ds.setRemoveAbandonedOnBorrow(true); // Check for abandoned connections on borrow + ds.setRemoveAbandonedOnMaintenance(true); // Remove abandoned connections during maintenance + ds.setRemoveAbandonedTimeout(60); // Mark as abandoned after 60 seconds + + return ds; + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/store/DruidDataSourceProvider.java b/console/src/main/java/org/apache/seata/mcp/store/DruidDataSourceProvider.java new file mode 100644 index 00000000000..f3dc0cd7248 --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/store/DruidDataSourceProvider.java @@ -0,0 +1,66 @@ +/* + * 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. + */ +package org.apache.seata.mcp.store; + +import com.alibaba.druid.pool.DruidDataSource; +import org.apache.seata.mcp.store.db.AbstractMCPDataSourceProvider; + +import javax.sql.DataSource; +import java.sql.Connection; + +/** + * The druid datasource provider + */ +public class DruidDataSourceProvider extends AbstractMCPDataSourceProvider { + + @Override + public DataSource doGenerate() { + DruidDataSource ds = new DruidDataSource(); + ds.setDriverClassName(getDriverClassName()); + ds.setDriverClassLoader(getDriverClassLoader()); + ds.setUrl(getUrl()); + ds.setUsername(getUser()); + ds.setPassword(getPassword()); + ds.setDefaultCatalog(getDatabaseName()); + + ds.setInitialSize(getMinConn()); // Initial connection pool size + ds.setMaxActive(getMaxConn()); // Maximum active connections + ds.setMinIdle(getMinConn()); // Minimum idle connections + ds.setMaxWait(getMaxWait()); // Maximum wait time for connection acquisition + + ds.setTestOnBorrow(true); // Test connections when borrowing + ds.setTestOnReturn(false); // Don't test on return for performance + ds.setTestWhileIdle(true); // Test idle connections + ds.setTimeBetweenEvictionRunsMillis(60000); // Run evictor every 60 seconds + ds.setMinEvictableIdleTimeMillis(180000); // Min idle time before eviction (3 minutes) + ds.setMaxEvictableIdleTimeMillis(300000); // Max idle time before eviction (5 minutes) + ds.setValidationQuery(getValidationQuery(getDBType())); + ds.setValidationQueryTimeout(5); // Validation timeout (seconds) + + ds.setConnectionErrorRetryAttempts(3); // Retry 3 times on connection error + ds.setBreakAfterAcquireFailure(true); // Break after all retries fail + + ds.setPoolPreparedStatements(true); // Pool prepared statements for better performance + ds.setMaxPoolPreparedStatementPerConnectionSize(20); + ds.setDefaultAutoCommit(true); + + ds.setUseOracleImplicitCache(false); + ds.setDefaultTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); + + return ds; + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/store/HikariDataSourceProvider.java b/console/src/main/java/org/apache/seata/mcp/store/HikariDataSourceProvider.java new file mode 100644 index 00000000000..9555c09fbd6 --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/store/HikariDataSourceProvider.java @@ -0,0 +1,76 @@ +/* + * 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. + */ +package org.apache.seata.mcp.store; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import com.zaxxer.hikari.util.IsolationLevel; +import org.apache.seata.mcp.store.db.AbstractMCPDataSourceProvider; + +import javax.sql.DataSource; +import java.util.Properties; + +/** + * The hikari datasource provider + */ +public class HikariDataSourceProvider extends AbstractMCPDataSourceProvider { + + @Override + public DataSource doGenerate() { + Properties properties = new Properties(); + properties.setProperty("dataSource.cachePrepStmts", "true"); // Enable prepared statement caching + properties.setProperty("dataSource.prepStmtCacheSize", "250"); // Number of prepared statements to cache + properties.setProperty("dataSource.prepStmtCacheSqlLimit", "2048"); // Maximum SQL statement length to cache + properties.setProperty("dataSource.useServerPrepStmts", "true"); // Use server-side prepared statements + properties.setProperty("dataSource.useLocalSessionState", "true"); // Track transaction state locally + properties.setProperty( + "dataSource.rewriteBatchedStatements", "true"); // Rewrite batched statements for efficiency + properties.setProperty("dataSource.cacheResultSetMetadata", "true"); // Cache result set metadata + properties.setProperty("dataSource.cacheServerConfiguration", "true"); // Cache server configuration + properties.setProperty("dataSource.elideSetAutoCommits", "true"); // Don't send autocommit if unchanged + properties.setProperty("dataSource.maintainTimeStats", "false"); // Disable time statistics tracking + + HikariConfig config = new HikariConfig(properties); + + config.setDriverClassName(getDriverClassName()); + config.setJdbcUrl(getUrl()); + config.setUsername(getUser()); + config.setPassword(getPassword()); + config.setCatalog(getDatabaseName()); + + config.setMaximumPoolSize(getMaxConn()); // Maximum size of connection pool + config.setMinimumIdle(getMinConn()); // Minimum number of idle connections + config.setIdleTimeout(300000); // Maximum idle time (5 minutes) + + config.setConnectionTimeout(getMaxWait()); // Maximum wait time for connection + config.setInitializationFailTimeout(-1); // No timeout for pool initialization + + config.setConnectionTestQuery(getValidationQuery(getDBType())); // Query to validate connections + config.setValidationTimeout(5000); // Validation timeout (5 seconds) + config.setMaxLifetime(1800000); // Maximum connection lifetime (30 minutes) + config.setKeepaliveTime(60000); // Keepalive interval (60 seconds) + + config.setAutoCommit(true); // Auto-commit connections by default + config.setTransactionIsolation(IsolationLevel.TRANSACTION_READ_COMMITTED.name()); + + properties.setProperty("connectionRetryAttempts", "3"); // Attempt connection 3 times + properties.setProperty("connectionRetryDelay", "1000"); // Wait 1 second between retries + properties.setProperty("connectionTimeoutMs", "5000"); // Connection timeout 5 seconds + + return new HikariDataSource(config); + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/store/SqlExecutionTemplate.java b/console/src/main/java/org/apache/seata/mcp/store/SqlExecutionTemplate.java new file mode 100644 index 00000000000..d2c1625ba6d --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/store/SqlExecutionTemplate.java @@ -0,0 +1,197 @@ +/* + * 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. + */ +package org.apache.seata.mcp.store; + +import org.apache.seata.common.exception.StoreException; +import org.apache.seata.mcp.entity.vo.BusinessQueryResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +public class SqlExecutionTemplate { + + private static final Logger LOGGER = LoggerFactory.getLogger(SqlExecutionTemplate.class); + + private final SqlSafetyValidator sqlSafetyValidator; + + @Value("${seata.mcp.query.max-rows:500}") + private int maxRows = 500; + + @Value("${seata.mcp.query.timeout-seconds:30}") + private int timeoutSeconds = 30; + + @Value("${seata.mcp.query.fetch-size:100}") + private int fetchSize = 100; + + public SqlExecutionTemplate(SqlSafetyValidator sqlSafetyValidator) { + this.sqlSafetyValidator = sqlSafetyValidator; + } + + private DataSource getDataSource(String resourceId) { + try { + return DataSourceFactory.getDataSource(resourceId); + } catch (Exception e) { + LOGGER.error("Failed to get the data source, resourceId: {}", resourceId); + throw new StoreException("Unable to get the data source: " + resourceId); + } + } + + public BusinessQueryResult query(String resourceId, String sql, Object... params) { + sqlSafetyValidator.validateMysqlSelect(sql); + return doQuery(resourceId, sql, maxRows, true, params); + } + + public BusinessQueryResult queryWithMaxRows(String resourceId, String sql, int queryMaxRows, Object... params) { + sqlSafetyValidator.validateMysqlSelect(sql); + int effectiveMaxRows = queryMaxRows <= 0 ? maxRows : Math.min(queryMaxRows, maxRows); + return doQuery(resourceId, sql, effectiveMaxRows, true, params); + } + + public BusinessQueryResult trustedQuery(String resourceId, String sql, Object... params) { + return doQuery(resourceId, sql, maxRows, false, params); + } + + private BusinessQueryResult doQuery( + String resourceId, String sql, int effectiveMaxRows, boolean limitRows, Object... params) { + Connection conn = null; + PreparedStatement ps = null; + ResultSet rs = null; + long start = System.currentTimeMillis(); + + try { + conn = getConnection(resourceId); + conn.setReadOnly(true); + ps = conn.prepareStatement(sql); + ps.setQueryTimeout(timeoutSeconds); + ps.setFetchSize(fetchSize); + if (limitRows) { + ps.setMaxRows(effectiveMaxRows + 1); + } + if (params != null) { + for (int i = 0; i < params.length; i++) { + ps.setObject(i + 1, params[i]); + } + } + + rs = ps.executeQuery(); + ResultSetMetaData metaData = rs.getMetaData(); + int columnCount = metaData.getColumnCount(); + List columns = new ArrayList<>(); + for (int i = 1; i <= columnCount; i++) { + columns.add(metaData.getColumnLabel(i)); + } + + List> results = new ArrayList<>(); + while (rs.next()) { + if (limitRows && results.size() >= effectiveMaxRows) { + return buildResult(resourceId, columns, results, effectiveMaxRows, true, start); + } + Map row = new LinkedHashMap<>(); + for (int i = 1; i <= columnCount; i++) { + String columnName = columns.get(i - 1); + Object value = rs.getObject(i); + row.put(columnName, value); + } + results.add(row); + } + + return buildResult(resourceId, columns, results, effectiveMaxRows, false, start); + } catch (SQLException e) { + LOGGER.error("The query failed, resourceId: {}", resourceId); + throw new StoreException("The query execution failed"); + } finally { + closeResources(rs, ps, conn); + } + } + + public Map queryForObject(String resourceId, String sql, Object... params) { + List> results = query(resourceId, sql, params).getRows(); + return results.isEmpty() ? null : results.get(0); + } + + private BusinessQueryResult buildResult( + String resourceId, + List columns, + List> rows, + int effectiveMaxRows, + boolean truncated, + long start) { + BusinessQueryResult result = new BusinessQueryResult(); + result.setResourceId(resourceId); + result.setColumns(columns); + result.setRows(rows); + result.setRowCount(rows.size()); + result.setTruncated(truncated); + result.setMaxRows(effectiveMaxRows); + result.setExecutionTimeMs(System.currentTimeMillis() - start); + return result; + } + + private void closeResources(ResultSet rs, Statement stmt, Connection conn) { + if (rs != null) { + try { + rs.close(); + } catch (SQLException e) { + LOGGER.warn("fail to close ResultSet", e); + } + } + + if (stmt != null) { + try { + stmt.close(); + } catch (SQLException e) { + LOGGER.warn("fail to close Statement", e); + } + } + + closeConnection(conn); + } + + private void closeConnection(Connection conn) { + if (conn != null) { + try { + conn.close(); + } catch (SQLException e) { + LOGGER.warn("fail to close Connection", e); + } + } + } + + private Connection getConnection(String resourceId) { + try { + return getDataSource(resourceId).getConnection(); + } catch (Exception e) { + LOGGER.error("Get The Business DataSource Connection: {} failed", resourceId); + DataSourceFactory.removeErrorDataSource(resourceId, e); + throw new StoreException(e); + } + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/store/SqlSafetyValidator.java b/console/src/main/java/org/apache/seata/mcp/store/SqlSafetyValidator.java new file mode 100644 index 00000000000..aca97fdf5fd --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/store/SqlSafetyValidator.java @@ -0,0 +1,130 @@ +/* + * 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. + */ +package org.apache.seata.mcp.store; + +import com.alibaba.druid.DbType; +import com.alibaba.druid.sql.SQLUtils; +import com.alibaba.druid.sql.ast.SQLStatement; +import com.alibaba.druid.sql.ast.statement.SQLSelectQueryBlock; +import com.alibaba.druid.sql.ast.statement.SQLSelectStatement; +import com.alibaba.druid.sql.visitor.SQLASTVisitorAdapter; +import com.alibaba.druid.sql.visitor.SchemaStatVisitor; +import com.alibaba.druid.stat.TableStat; +import org.apache.seata.common.exception.StoreException; +import org.apache.seata.common.util.StringUtils; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Locale; + +@Component +public class SqlSafetyValidator { + + private static final String UNDO_LOG_TABLE = "undo_log"; + + public SQLSelectStatement validateMysqlSelect(String sql) { + if (StringUtils.isBlank(sql)) { + throw new StoreException("SQL cannot be empty"); + } + List statements; + try { + statements = SQLUtils.parseStatements(sql, DbType.mysql); + } catch (Exception e) { + throw new StoreException("SQL parse failed"); + } + if (statements.size() != 1) { + throw new StoreException("Only a single SELECT statement is allowed"); + } + SQLStatement statement = statements.get(0); + if (!(statement instanceof SQLSelectStatement)) { + throw new StoreException("Only SELECT statements are allowed"); + } + SQLSelectStatement selectStatement = (SQLSelectStatement) statement; + if (hasLockClause(selectStatement)) { + throw new StoreException("SELECT locking clauses are not allowed"); + } + if (containsUndoLog(selectStatement)) { + throw new StoreException("Querying undo_log is not allowed"); + } + return selectStatement; + } + + public SQLSelectStatement validateMysqlSelect(String sql, String databaseName) { + SQLSelectStatement selectStatement = validateMysqlSelect(sql); + validateDatabaseScope(selectStatement, databaseName); + return selectStatement; + } + + private boolean hasLockClause(SQLSelectStatement statement) { + final boolean[] result = new boolean[] {false}; + statement.accept(new SQLASTVisitorAdapter() { + @Override + public boolean visit(SQLSelectQueryBlock queryBlock) { + if (queryBlock.isForUpdate() || queryBlock.isForShare()) { + result[0] = true; + } + return true; + } + }); + return result[0]; + } + + private boolean containsUndoLog(SQLSelectStatement statement) { + SchemaStatVisitor visitor = SQLUtils.createSchemaStatVisitor(DbType.mysql); + statement.accept(visitor); + for (TableStat.Name name : visitor.getTables().keySet()) { + String tableName = normalizeTableName(name.getName()); + if (UNDO_LOG_TABLE.equals(tableName)) { + return true; + } + } + return false; + } + + private void validateDatabaseScope(SQLSelectStatement statement, String databaseName) { + if (StringUtils.isBlank(databaseName)) { + throw new StoreException("Database name cannot be empty"); + } + SchemaStatVisitor visitor = SQLUtils.createSchemaStatVisitor(DbType.mysql); + statement.accept(visitor); + String normalizedDatabase = normalizeIdentifier(databaseName); + for (TableStat.Name name : visitor.getTables().keySet()) { + String tableName = name.getName(); + int index = tableName.lastIndexOf('.'); + if (index < 0) { + continue; + } + String queriedDatabaseName = normalizeIdentifier(tableName.substring(0, index)); + if (!normalizedDatabase.equals(queriedDatabaseName)) { + throw new StoreException("Cross-database query is not allowed"); + } + } + } + + private String normalizeTableName(String tableName) { + String normalized = tableName; + int index = normalized.lastIndexOf('.'); + if (index >= 0) { + normalized = normalized.substring(index + 1); + } + return normalized.replace("`", "").replace("\"", "").toLowerCase(Locale.ROOT); + } + + private String normalizeIdentifier(String identifier) { + return identifier.replace("`", "").replace("\"", "").toLowerCase(Locale.ROOT); + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/store/db/AbstractMCPDataSourceProvider.java b/console/src/main/java/org/apache/seata/mcp/store/db/AbstractMCPDataSourceProvider.java new file mode 100644 index 00000000000..198d47d2eaa --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/store/db/AbstractMCPDataSourceProvider.java @@ -0,0 +1,264 @@ +/* + * 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. + */ +package org.apache.seata.mcp.store.db; + +import org.apache.seata.common.ConfigurationKeys; +import org.apache.seata.common.exception.ShouldNeverHappenException; +import org.apache.seata.common.exception.StoreException; +import org.apache.seata.common.util.StringUtils; +import org.apache.seata.core.constants.DBType; +import org.apache.seata.mcp.core.props.BusinessDataSourcesProperties; + +import javax.sql.DataSource; +import java.io.File; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Stream; + +import static org.apache.seata.common.DefaultValues.DEFAULT_DB_MAX_CONN; +import static org.apache.seata.common.DefaultValues.DEFAULT_DB_MIN_CONN; + +public abstract class AbstractMCPDataSourceProvider { + + private String resourceId; + + private static final String MYSQL_DRIVER_CLASS_NAME = "com.mysql.jdbc.Driver"; + + private static final String MYSQL8_DRIVER_CLASS_NAME = "com.mysql.cj.jdbc.Driver"; + + private static final String MYSQL_DRIVER_FILE_PREFIX = "mysql-connector-j"; + + private static final Map DRIVER_LOADERS; + + private static final long DEFAULT_DB_MAX_WAIT = 5000; + + static { + DRIVER_LOADERS = createMysqlDriverClassLoaders(); + } + + public DataSource generate() { + validate(); + return doGenerate(); + } + + public DataSource generateByResourceId(String resourceId) { + this.resourceId = resourceId; + return generate(); + } + + public void validate() { + String driverClassName = getDriverClassName(); + ClassLoader loader = getDriverClassLoader(); + if (null == loader) { + throw new StoreException("class loader set error, you should not use the Bootstrap classloader"); + } + try { + loader.loadClass(driverClassName); + } catch (ClassNotFoundException exx) { + String folderPath = System.getProperty("loader.path"); + if (folderPath == null) { + folderPath = System.getProperty("java.class.path"); + } + String driverClassPath = Stream.of(folderPath.split(File.pathSeparator)) + .map(File::new) + .filter(File::exists) + .map(file -> file.isFile() ? file.getParentFile() : file) + .filter(Objects::nonNull) + .filter(File::isDirectory) + .map(file -> (MYSQL8_DRIVER_CLASS_NAME.equals(driverClassName) + || MYSQL_DRIVER_CLASS_NAME.equals(driverClassName)) + ? new File(file, "jdbc") + : file) + .filter(File::exists) + .filter(File::isDirectory) + .distinct() + .findAny() + .map(File::getAbsolutePath) + .orElseThrow(() -> new ShouldNeverHappenException("cannot find jdbc folder")); + throw new StoreException(String.format( + "The driver {%s} cannot be found in the path %s. Please ensure that the appropriate database driver dependencies are included in the classpath.", + driverClassName, driverClassPath)); + } + } + + public abstract DataSource doGenerate(); + + protected DBType getDBType() { + BusinessDataSourcesProperties.DataSourceProperties properties = getDataSourceProperties(); + if (properties != null) { + return DBType.valueof(properties.getDbType()); + } + return null; + } + + protected BusinessDataSourcesProperties.DataSourceProperties getDataSourceProperties() { + Map datasourceProperties = + BusinessDataSourcesProperties.getDatasources(); + if (StringUtils.isBlank(resourceId)) { + if (datasourceProperties.size() == 1) { + return datasourceProperties.values().iterator().next(); + } + throw new StoreException("resourceId is not specified and there are multiple datasource properties"); + } + return datasourceProperties.get(resourceId); + } + + protected String getDriverClassName() { + BusinessDataSourcesProperties.DataSourceProperties properties = getDataSourceProperties(); + String driverClassName = ""; + if (properties != null) { + driverClassName = properties.getDriverClassName(); + if (StringUtils.isBlank(driverClassName)) { + throw new StoreException( + String.format("the {%s} can't be empty", ConfigurationKeys.STORE_DB_DRIVER_CLASS_NAME)); + } + } + return driverClassName; + } + + protected Long getMaxWait() { + BusinessDataSourcesProperties.DataSourceProperties properties = getDataSourceProperties(); + if (properties != null && properties.getMaxWait() != null) { + return properties.getMaxWait(); + } + return DEFAULT_DB_MAX_WAIT; + } + + protected ClassLoader getDriverClassLoader() { + return DRIVER_LOADERS.getOrDefault(getDriverClassName(), this.getClass().getClassLoader()); + } + + private static Map createMysqlDriverClassLoaders() { + Map loaders = new HashMap<>(); + String cp = System.getProperty("loader.path"); + if (cp == null) { + cp = System.getProperty("java.class.path"); + } + if (cp == null || cp.isEmpty()) { + return loaders; + } + Stream.of(cp.split(File.pathSeparator)) + .map(File::new) + .filter(File::exists) + .map(file -> file.isFile() ? file.getParentFile() : file) + .filter(Objects::nonNull) + .filter(File::isDirectory) + .map(file -> new File(file, "jdbc")) + .filter(File::exists) + .filter(File::isDirectory) + .distinct() + .flatMap(file -> { + File[] files = file.listFiles((f, name) -> name.startsWith(MYSQL_DRIVER_FILE_PREFIX)); + if (files != null) { + return Stream.of(files); + } else { + return Stream.of(); + } + }) + .forEach(file -> { + if (loaders.containsKey(MYSQL8_DRIVER_CLASS_NAME) && loaders.containsKey(MYSQL_DRIVER_CLASS_NAME)) { + return; + } + try { + URL url = file.toURI().toURL(); + ClassLoader loader = new URLClassLoader(new URL[] {url}, ClassLoader.getSystemClassLoader()); + try { + loader.loadClass(MYSQL8_DRIVER_CLASS_NAME); + loaders.putIfAbsent(MYSQL8_DRIVER_CLASS_NAME, loader); + } catch (ClassNotFoundException e) { + loaders.putIfAbsent(MYSQL_DRIVER_CLASS_NAME, loader); + } + } catch (MalformedURLException ignore) { + } + }); + return loaders; + } + + protected String getUrl() { + BusinessDataSourcesProperties.DataSourceProperties properties = getDataSourceProperties(); + String url = ""; + if (properties != null) { + url = properties.getUrl(); + if (StringUtils.isBlank(url)) { + throw new StoreException(String.format("the {%s} can't be empty", ConfigurationKeys.STORE_DB_URL)); + } + } + return url; + } + + protected String getDatabaseName() { + BusinessDataSourcesProperties.DataSourceProperties properties = getDataSourceProperties(); + if (properties == null || StringUtils.isBlank(properties.getDatabaseName())) { + throw new StoreException("the business datasource database name can't be empty"); + } + return properties.getDatabaseName(); + } + + protected String getUser() { + BusinessDataSourcesProperties.DataSourceProperties properties = getDataSourceProperties(); + String username = ""; + if (properties != null) { + username = properties.getUsername(); + if (StringUtils.isBlank(username)) { + throw new StoreException(String.format("the {%s} can't be empty", ConfigurationKeys.STORE_DB_USER)); + } + } + return username; + } + + protected String getPassword() { + BusinessDataSourcesProperties.DataSourceProperties properties = getDataSourceProperties(); + String password = ""; + if (properties != null) { + password = properties.getPassword(); + if (StringUtils.isBlank(password)) { + throw new StoreException(String.format("the {%s} can't be empty", ConfigurationKeys.STORE_DB_PASSWORD)); + } + } + return password; + } + + protected int getMinConn() { + BusinessDataSourcesProperties.DataSourceProperties properties = getDataSourceProperties(); + int minConn = -1; + if (properties != null) { + minConn = properties.getMinConn(); + } + return minConn < 0 ? DEFAULT_DB_MIN_CONN : minConn; + } + + protected int getMaxConn() { + BusinessDataSourcesProperties.DataSourceProperties properties = getDataSourceProperties(); + int maxConn = -1; + if (properties != null) { + maxConn = properties.getMaxConn(); + } + return maxConn < 0 ? DEFAULT_DB_MAX_CONN : maxConn; + } + + protected String getValidationQuery(DBType dbType) { + if (DBType.ORACLE.equals(dbType)) { + return "select sysdate from dual"; + } else { + return "select 1"; + } + } +} diff --git a/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java b/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java new file mode 100644 index 00000000000..f49d29474a8 --- /dev/null +++ b/console/src/main/java/org/apache/seata/mcp/tools/BusinessDataSourceTools.java @@ -0,0 +1,155 @@ +/* + * 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. + */ +package org.apache.seata.mcp.tools; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.seata.common.exception.StoreException; +import org.apache.seata.mcp.entity.vo.BusinessQueryResult; +import org.apache.seata.mcp.entity.vo.MysqlColumnInfo; +import org.apache.seata.mcp.entity.vo.MysqlDataSourceInfo; +import org.apache.seata.mcp.entity.vo.MysqlTableInfo; +import org.apache.seata.mcp.service.BusinessDataSourceService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springaicommunity.mcp.annotation.McpResource; +import org.springaicommunity.mcp.annotation.McpTool; +import org.springaicommunity.mcp.annotation.McpToolParam; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; + +@Service +public class BusinessDataSourceTools { + + private static final Logger LOGGER = LoggerFactory.getLogger(BusinessDataSourceTools.class); + + private final BusinessDataSourceService dataSourceService; + + private final ObjectMapper objectMapper; + + public BusinessDataSourceTools(BusinessDataSourceService dataSourceService, ObjectMapper objectMapper) { + this.dataSourceService = dataSourceService; + this.objectMapper = objectMapper; + } + + @McpTool(name = "getDataSources", description = "Get all MySQL business data sources without sensitive fields") + public List getDataSources() { + LOGGER.info("User tries to get MySQL business data sources"); + return dataSourceService.getMysqlDataSources(); + } + + @McpTool(name = "getTableNames", description = "Get table names in the data source database") + public List getTableNames( + @McpToolParam( + description = "The identity of the data source, for example business-ds://biz", + required = true) + String resourceId) { + LOGGER.info("User tries to get MySQL table names, resourceId: {}", resourceId); + return dataSourceService.getMysqlTableNames(resourceId); + } + + @McpTool(name = "getTableSchema", description = "Get table columns in the data source database") + public List getTableSchema( + @McpToolParam( + description = "The identity of the data source, for example business-ds://biz", + required = true) + String resourceId, + @McpToolParam(description = "MySQL table name", required = true) String tableName) { + LOGGER.info("User tries to get MySQL table schema, resourceId: {}, tableName: {}", resourceId, tableName); + return dataSourceService.getMysqlTableSchema(resourceId, tableName); + } + + @McpTool( + name = "queryTable", + description = + "Query a table in the data source database with optional column list, equality filters, and row limit") + public BusinessQueryResult queryTable( + @McpToolParam( + description = "The identity of the data source, for example business-ds://biz", + required = true) + String resourceId, + @McpToolParam(description = "MySQL table name", required = true) String tableName, + @McpToolParam(description = "Column names to select", required = false) List columns, + @McpToolParam(description = "Equality filters keyed by column name", required = false) + Map filters, + @McpToolParam(description = "Maximum rows to return", required = false) Integer limit) { + LOGGER.info("User tries to query MySQL table, resourceId: {}, tableName: {}", resourceId, tableName); + return dataSourceService.queryMysqlTable(resourceId, tableName, columns, filters, limit); + } + + @McpTool(name = "explainSql", description = "Explain a safe MySQL SELECT SQL statement") + public BusinessQueryResult explainSql( + @McpToolParam( + description = "The identity of the data source, for example business-ds://biz", + required = true) + String resourceId, + @McpToolParam(description = "MySQL SELECT SQL statement", required = true) String sql) { + LOGGER.info("User tries to explain MySQL sql, resourceId: {}", resourceId); + return dataSourceService.explainMysqlSql(resourceId, sql); + } + + @McpTool(name = "runSql", description = "Execute a safe MySQL SELECT query against a business data source") + public BusinessQueryResult runSql( + @McpToolParam(description = "MySQL SELECT SQL statement", required = true) String sql, + @McpToolParam( + description = "The identity of the data source, for example business-ds://biz", + required = true) + String resourceId) { + LOGGER.info("User tries to run MySQL sql, resourceId: {}", resourceId); + return dataSourceService.runSql(sql, resourceId); + } + + @McpResource( + name = "mysqlDataSources", + title = "MySQL business data sources", + uri = "mysql-db://datasources", + description = "MySQL business data source list without sensitive fields", + mimeType = "application/json") + public String mysqlDataSourcesResource() { + return toJson(dataSourceService.getMysqlDataSources()); + } + + @McpResource( + name = "mysqlTables", + title = "MySQL tables", + uri = "mysql-db://{resourceId}/tables", + description = "MySQL table list for a business data source database", + mimeType = "application/json") + public String mysqlTablesResource(String resourceId) { + return toJson(dataSourceService.getMysqlTableNames(resourceId)); + } + + @McpResource( + name = "mysqlTableSchema", + title = "MySQL table schema", + uri = "mysql-db://{resourceId}/{tableName}/schema", + description = "MySQL column list for a business table", + mimeType = "application/json") + public String mysqlTableSchemaResource(String resourceId, String tableName) { + return toJson(dataSourceService.getMysqlTableSchema(resourceId, tableName)); + } + + private String toJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new StoreException("Unable to serialize MCP resource"); + } + } +} diff --git a/console/src/main/resources/static/console-fe/src/app.tsx b/console/src/main/resources/static/console-fe/src/app.tsx index c64664e3bf1..53681b5a696 100644 --- a/console/src/main/resources/static/console-fe/src/app.tsx +++ b/console/src/main/resources/static/console-fe/src/app.tsx @@ -78,7 +78,7 @@ class App extends React.Component { get menu() { const { locale }: AppPropsType = this.props; const { MenuRouter = {} } = locale; - const { transactionInfo, globalLockInfo, clusterManager, sagaStatemachineDesigner } = MenuRouter; + const { transactionInfo, globalLockInfo, clusterManager, businessDataSource, sagaStatemachineDesigner } = MenuRouter; return { items: [ // { @@ -97,6 +97,10 @@ class App extends React.Component { key: '/cluster/list', label: clusterManager, }, + { + key: '/business-datasource/list', + label: businessDataSource, + }, { key: '/sagastatemachinedesigner', label: sagaStatemachineDesigner, diff --git a/console/src/main/resources/static/console-fe/src/locales/en-us.ts b/console/src/main/resources/static/console-fe/src/locales/en-us.ts index 2ea2c9f66b8..0ab4a1cdcfc 100644 --- a/console/src/main/resources/static/console-fe/src/locales/en-us.ts +++ b/console/src/main/resources/static/console-fe/src/locales/en-us.ts @@ -23,6 +23,7 @@ const enUs: ILocale = { transactionInfo: 'TransactionInfo', globalLockInfo: 'GlobalLockInfo', clusterManager: 'ClusterManager', + businessDataSource: 'DataSource', sagaStatemachineDesigner: 'SagaStatemachineDesigner', }, Header: { @@ -140,6 +141,47 @@ const enUs: ILocale = { transactionEndpoint: 'Transaction Endpoint', metadataDialogTitle: 'Metadata', }, + BusinessDataSource: { + title: 'Data Source', + subTitle: 'Business Data Source Management', + keywordLabel: 'Keyword', + keywordPlaceholder: 'Search name, resourceId, database or datasource', + resetButtonLabel: 'Reset', + searchButtonLabel: 'Query', + refreshButtonLabel: 'Refresh', + createButtonLabel: 'Create Data Source', + createDialogTitle: 'Create Business Data Source', + testButtonLabel: 'Test Connection', + confirmButtonLabel: 'Confirm', + cancelButtonLabel: 'Cancel', + nameLabel: 'Name', + urlLabel: 'JDBC URL', + usernameLabel: 'Username', + passwordLabel: 'Password', + passwordSecretRefLabel: 'Password Secret Ref', + datasourceLabel: 'Datasource', + minConnLabel: 'Min Connections', + maxConnLabel: 'Max Connections', + maxWaitLabel: 'Max Wait(ms)', + databaseNameLabel: 'Database', + enabledLabel: 'Enabled', + dynamicLabel: 'Dynamic', + operations: 'Operations', + deleteLabel: 'Delete', + staticSourceLabel: 'Static', + yesLabel: 'Yes', + noLabel: 'No', + successLabel: 'Success', + failedLabel: 'Failed', + validationRequiredMessage: 'Please enter name, JDBC URL and username', + passwordRequiredMessage: 'Please enter password or password secret ref', + createSuccessMessage: 'Data source registered successfully', + testSuccessMessage: 'Connection test succeeded', + testFailedMessage: 'Connection test failed', + deleteTitle: 'Delete data source', + deleteContent: 'Confirm to delete dynamic data source', + deleteSuccessMessage: 'Data source deleted successfully', + }, codeMessage: { 200: 'The server successfully returned the requested data.', 201: 'New or modified data successful.', diff --git a/console/src/main/resources/static/console-fe/src/locales/index.d.ts b/console/src/main/resources/static/console-fe/src/locales/index.d.ts index 806b6f91f0d..1549943d1c6 100644 --- a/console/src/main/resources/static/console-fe/src/locales/index.d.ts +++ b/console/src/main/resources/static/console-fe/src/locales/index.d.ts @@ -26,5 +26,6 @@ export interface ILocale { TransactionInfo: ILocaleMap; GlobalLockInfo: ILocaleMap; ClusterManager: ILocaleMap; + BusinessDataSource: ILocaleMap; codeMessage: ILocaleMap; } diff --git a/console/src/main/resources/static/console-fe/src/locales/zh-cn.ts b/console/src/main/resources/static/console-fe/src/locales/zh-cn.ts index c4b4f652f28..fc47653b431 100644 --- a/console/src/main/resources/static/console-fe/src/locales/zh-cn.ts +++ b/console/src/main/resources/static/console-fe/src/locales/zh-cn.ts @@ -23,6 +23,7 @@ const zhCn: ILocale = { transactionInfo: '事务信息', globalLockInfo: '全局锁信息', clusterManager: '集群管理', + businessDataSource: '数据源配置', sagaStatemachineDesigner: 'Saga状态机设计器', }, Header: { @@ -140,6 +141,47 @@ const zhCn: ILocale = { transactionEndpoint: '事务端点', metadataDialogTitle: '元数据', }, + BusinessDataSource: { + title: '数据源配置', + subTitle: '业务数据源管理', + keywordLabel: '关键字', + keywordPlaceholder: '搜索名称、resourceId、数据库或连接池', + resetButtonLabel: '重置', + searchButtonLabel: '查询', + refreshButtonLabel: '刷新', + createButtonLabel: '新增数据源', + createDialogTitle: '新增业务数据源', + testButtonLabel: '测试连接', + confirmButtonLabel: '确认', + cancelButtonLabel: '取消', + nameLabel: '名称', + urlLabel: 'JDBC地址', + usernameLabel: '用户名', + passwordLabel: '密码', + passwordSecretRefLabel: '密码密钥引用', + datasourceLabel: '连接池', + minConnLabel: '最小连接数', + maxConnLabel: '最大连接数', + maxWaitLabel: '最大等待时间(ms)', + databaseNameLabel: '数据库', + enabledLabel: '启用', + dynamicLabel: '动态注册', + operations: '操作', + deleteLabel: '删除', + staticSourceLabel: '静态配置', + yesLabel: '是', + noLabel: '否', + successLabel: '成功', + failedLabel: '失败', + validationRequiredMessage: '请输入名称、JDBC地址和用户名', + passwordRequiredMessage: '请输入密码或密码密钥引用', + createSuccessMessage: '数据源注册成功', + testSuccessMessage: '连接测试成功', + testFailedMessage: '连接测试失败', + deleteTitle: '删除数据源', + deleteContent: '确认删除动态数据源', + deleteSuccessMessage: '数据源删除成功', + }, codeMessage: { 200: '服务器成功返回请求的数据。', 201: '新建或修改数据成功。', diff --git a/console/src/main/resources/static/console-fe/src/pages/BusinessDataSource/BusinessDataSource.tsx b/console/src/main/resources/static/console-fe/src/pages/BusinessDataSource/BusinessDataSource.tsx new file mode 100644 index 00000000000..c5784d76de9 --- /dev/null +++ b/console/src/main/resources/static/console-fe/src/pages/BusinessDataSource/BusinessDataSource.tsx @@ -0,0 +1,474 @@ +/* + * 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. + */ +import React from 'react'; +import { ConfigProvider, Table, Button, Form, Icon, Dialog, Select, Input, Message } from '@alicloud/console-components'; +import Actions from '@alicloud/console-components-actions'; +import { withRouter } from 'react-router-dom'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import Page from '@/components/Page'; +import { GlobalProps } from '@/module'; +import { + BusinessDataSourceForm, + BusinessDataSourceInfo, + fetchBusinessDataSources, + registerBusinessDataSource, + testBusinessDataSource, + unregisterBusinessDataSource, +} from '@/service/businessDataSource'; + +import './index.scss'; + +const FormItem = Form.Item; + +type BusinessDataSourceLocale = { + title?: string; + subTitle?: string; + keywordLabel?: string; + keywordPlaceholder?: string; + resetButtonLabel?: string; + searchButtonLabel?: string; + refreshButtonLabel?: string; + createButtonLabel?: string; + createDialogTitle?: string; + testButtonLabel?: string; + confirmButtonLabel?: string; + cancelButtonLabel?: string; + nameLabel?: string; + urlLabel?: string; + usernameLabel?: string; + passwordLabel?: string; + passwordSecretRefLabel?: string; + datasourceLabel?: string; + minConnLabel?: string; + maxConnLabel?: string; + maxWaitLabel?: string; + databaseNameLabel?: string; + enabledLabel?: string; + dynamicLabel?: string; + operations?: string; + deleteLabel?: string; + staticSourceLabel?: string; + yesLabel?: string; + noLabel?: string; + successLabel?: string; + failedLabel?: string; + validationRequiredMessage?: string; + passwordRequiredMessage?: string; + createSuccessMessage?: string; + testSuccessMessage?: string; + testFailedMessage?: string; + deleteTitle?: string; + deleteContent?: string; + deleteSuccessMessage?: string; +}; + +type BusinessDataSourceState = { + list: BusinessDataSourceInfo[]; + keyword: string; + loading: boolean; + dialogVisible: boolean; + dialogLoading: boolean; + testLoading: boolean; + form: BusinessDataSourceForm; +}; + +const defaultForm: BusinessDataSourceForm = { + name: '', + url: '', + username: '', + password: '', + passwordSecretRef: '', + datasource: 'druid', + minConn: 10, + maxConn: 100, + maxWait: 5000, +}; + +class BusinessDataSource extends React.Component { + static displayName = 'BusinessDataSource'; + + static propTypes = { + locale: PropTypes.object, + }; + + state: BusinessDataSourceState = { + list: [], + keyword: '', + loading: false, + dialogVisible: false, + dialogLoading: false, + testLoading: false, + form: { ...defaultForm }, + }; + + componentDidMount = () => { + this.loadDataSources(); + }; + + loadDataSources = async () => { + this.setState(prevState => ({ + ...prevState, + loading: true, + })); + try { + const list = await fetchBusinessDataSources(); + this.setState(prevState => ({ + ...prevState, + list, + loading: false, + })); + } catch (error) { + this.setState(prevState => ({ + ...prevState, + loading: false, + })); + } + }; + + searchFilterOnChange = (value: string) => { + this.setState(prevState => ({ + ...prevState, + keyword: value, + })); + }; + + resetSearchFilter = () => { + this.setState(prevState => ({ + ...prevState, + keyword: '', + })); + }; + + showCreateDialog = () => { + this.setState(prevState => ({ + ...prevState, + dialogVisible: true, + form: { ...defaultForm }, + })); + }; + + closeCreateDialog = () => { + this.setState(prevState => ({ + ...prevState, + dialogVisible: false, + dialogLoading: false, + testLoading: false, + })); + }; + + formOnChange = (key: keyof BusinessDataSourceForm, value: string | number) => { + this.setState(prevState => ({ + ...prevState, + form: { + ...prevState.form, + [key]: value, + }, + })); + }; + + numberFormOnChange = (key: keyof BusinessDataSourceForm, value: string) => { + const nextValue = Number(value); + this.formOnChange(key, Number.isNaN(nextValue) ? 0 : nextValue); + }; + + validateForm = () => { + const { locale } = this.props; + const rawLocale = locale.BusinessDataSource; + const businessDataSourceLocale: BusinessDataSourceLocale = typeof rawLocale === 'object' && rawLocale !== null ? rawLocale : {}; + const { validationRequiredMessage, passwordRequiredMessage } = businessDataSourceLocale; + const { name, url, username, password, passwordSecretRef } = this.state.form; + if (!name || !url || !username) { + Message.error(validationRequiredMessage || 'Please enter name, URL and username'); + return false; + } + if (!password && !passwordSecretRef) { + Message.error(passwordRequiredMessage || 'Please enter password or password secret ref'); + return false; + } + return true; + }; + + showErrorMessage = (error: any) => { + if (error && error.message) { + Message.error(error.message); + } + }; + + createDataSource = async () => { + const { locale } = this.props; + const rawLocale = locale.BusinessDataSource; + const businessDataSourceLocale: BusinessDataSourceLocale = typeof rawLocale === 'object' && rawLocale !== null ? rawLocale : {}; + const { createSuccessMessage } = businessDataSourceLocale; + if (!this.validateForm()) { + return; + } + this.setState(prevState => ({ + ...prevState, + dialogLoading: true, + })); + try { + await registerBusinessDataSource(this.state.form); + Message.success(createSuccessMessage || 'Data source registered successfully'); + this.closeCreateDialog(); + this.loadDataSources(); + } catch (error) { + this.showErrorMessage(error); + this.setState(prevState => ({ + ...prevState, + dialogLoading: false, + })); + } + }; + + testDataSource = async () => { + const { locale } = this.props; + const rawLocale = locale.BusinessDataSource; + const businessDataSourceLocale: BusinessDataSourceLocale = typeof rawLocale === 'object' && rawLocale !== null ? rawLocale : {}; + const { testSuccessMessage, testFailedMessage } = businessDataSourceLocale; + if (!this.validateForm()) { + return; + } + this.setState(prevState => ({ + ...prevState, + testLoading: true, + })); + try { + const result = await testBusinessDataSource(this.state.form); + const detail = result && result.message ? `: ${result.message}` : ''; + if (result && result.success) { + Message.success(`${testSuccessMessage || 'Connection test succeeded'}${detail}`); + } else { + Message.error(`${testFailedMessage || 'Connection test failed'}${detail}`); + } + } catch (error) { + this.showErrorMessage(error); + } finally { + this.setState(prevState => ({ + ...prevState, + testLoading: false, + })); + } + }; + + deleteDataSource = (record: BusinessDataSourceInfo) => { + const { locale } = this.props; + const rawLocale = locale.BusinessDataSource; + const businessDataSourceLocale: BusinessDataSourceLocale = typeof rawLocale === 'object' && rawLocale !== null ? rawLocale : {}; + const { deleteTitle, deleteContent, deleteSuccessMessage } = businessDataSourceLocale; + Dialog.confirm({ + title: deleteTitle || 'Delete data source', + content: `${deleteContent || 'Confirm to delete dynamic data source'}: ${record.name}`, + onOk: async () => { + await unregisterBusinessDataSource(record.name); + Message.success(deleteSuccessMessage || 'Data source deleted successfully'); + this.loadDataSources(); + }, + }); + }; + + getFilteredList = () => { + const { keyword, list } = this.state; + const normalizedKeyword = keyword.trim().toLowerCase(); + if (!normalizedKeyword) { + return list; + } + return list.filter(item => { + return [item.name, item.resourceId, item.databaseName, item.datasource] + .filter(Boolean) + .some(value => String(value).toLowerCase().indexOf(normalizedKeyword) >= 0); + }); + }; + + render() { + const { locale } = this.props; + const rawLocale = locale.BusinessDataSource; + const businessDataSourceLocale: BusinessDataSourceLocale = typeof rawLocale === 'object' && rawLocale !== null ? rawLocale : {}; + const { + title, + subTitle, + keywordLabel, + keywordPlaceholder, + resetButtonLabel, + searchButtonLabel, + refreshButtonLabel, + createButtonLabel, + createDialogTitle, + testButtonLabel, + confirmButtonLabel, + cancelButtonLabel, + nameLabel, + urlLabel, + usernameLabel, + passwordLabel, + passwordSecretRefLabel, + datasourceLabel, + minConnLabel, + maxConnLabel, + maxWaitLabel, + databaseNameLabel, + enabledLabel, + dynamicLabel, + operations, + deleteLabel, + staticSourceLabel, + yesLabel, + noLabel, + } = businessDataSourceLocale; + const filteredList = this.getFilteredList(); + return ( + +
+ + { this.searchFilterOnChange(value); }} + /> + + + + {resetButtonLabel || 'Reset'} + + + + + {searchButtonLabel || 'Search'} + + + + + + + + +
+ + + + + + + (value ? (yesLabel || 'Yes') : (noLabel || 'No'))} /> + (value ? (yesLabel || 'Yes') : (noLabel || 'No'))} /> + { + return ( + + {record.dynamic ? ( + + ) : ( + {staticSourceLabel || 'Static'} + )} + + ); + }} + /> +
+ + +
+ + { this.formOnChange('name', value); }} /> + + + { this.formOnChange('url', value); }} + /> + + + { this.formOnChange('username', value); }} /> + + + { this.formOnChange('password', value); }} + /> + + + { this.formOnChange('passwordSecretRef', value); }} + /> + + + { this.numberFormOnChange('minConn', value); }} /> + + + { this.numberFormOnChange('maxConn', value); }} /> + + + { this.numberFormOnChange('maxWait', value); }} /> + + + + +
+
+
+ ); + } +} + +const mapStateToProps = (state: any) => ({ + locale: state.locale.locale, +}); + +export default connect(mapStateToProps)(withRouter(ConfigProvider.config(BusinessDataSource, {}))); diff --git a/console/src/main/resources/static/console-fe/src/pages/BusinessDataSource/index.scss b/console/src/main/resources/static/console-fe/src/pages/BusinessDataSource/index.scss new file mode 100644 index 00000000000..1a4c242f9df --- /dev/null +++ b/console/src/main/resources/static/console-fe/src/pages/BusinessDataSource/index.scss @@ -0,0 +1,31 @@ +/* + * 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. + */ + +.business-datasource-toolbar { + margin-bottom: 16px; +} + +.business-datasource-dialog-form { + .next-form-item { + margin-bottom: 16px; + } + + .next-input, + .next-select { + width: 360px; + } +} diff --git a/console/src/main/resources/static/console-fe/src/pages/BusinessDataSource/index.ts b/console/src/main/resources/static/console-fe/src/pages/BusinessDataSource/index.ts new file mode 100644 index 00000000000..168dc921820 --- /dev/null +++ b/console/src/main/resources/static/console-fe/src/pages/BusinessDataSource/index.ts @@ -0,0 +1,19 @@ +/* + * 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. + */ +import BusinessDataSource from './BusinessDataSource'; + +export default BusinessDataSource; diff --git a/console/src/main/resources/static/console-fe/src/router.tsx b/console/src/main/resources/static/console-fe/src/router.tsx index d3957a29992..a6348ab6157 100644 --- a/console/src/main/resources/static/console-fe/src/router.tsx +++ b/console/src/main/resources/static/console-fe/src/router.tsx @@ -19,6 +19,7 @@ import Overview from '@/pages/Overview'; import TransactionInfo from '@/pages/TransactionInfo'; import GlobalLockInfo from './pages/GlobalLockInfo'; import ClusterManager from './pages/ClusterManager'; +import BusinessDataSource from './pages/BusinessDataSource'; export default [ // { path: '/', exact: true, render: () => }, @@ -26,4 +27,5 @@ export default [ { path: '/transaction/list', component: TransactionInfo }, { path: '/globallock/list', component: GlobalLockInfo }, { path: '/cluster/list', component: ClusterManager }, + { path: '/business-datasource/list', component: BusinessDataSource }, ]; diff --git a/console/src/main/resources/static/console-fe/src/service/businessDataSource.ts b/console/src/main/resources/static/console-fe/src/service/businessDataSource.ts new file mode 100644 index 00000000000..d72f670dacd --- /dev/null +++ b/console/src/main/resources/static/console-fe/src/service/businessDataSource.ts @@ -0,0 +1,135 @@ +/* + * 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. + */ +import request from '@/utils/request'; + +export type BusinessDataSourceInfo = { + name: string; + resourceId: string; + databaseName: string; + datasource: string; + dynamic: boolean; + enabled: boolean; +}; + +export type BusinessDataSourceForm = { + name: string; + url: string; + username: string; + password: string; + passwordSecretRef: string; + datasource: string; + minConn: number; + maxConn: number; + maxWait: number; +}; + +export type BusinessDataSourceTestResult = { + success: boolean; + message: string; + validationQuery: string; + elapsedMs: number; +}; + +function base64ToArrayBuffer(base64: string): ArrayBuffer { + const binary = window.atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} + +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + const chunks: string[] = []; + for (let i = 0; i < bytes.length; i += 0x8000) { + chunks.push(String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + 0x8000)))); + } + return window.btoa(chunks.join('')); +} + +async function fetchPasswordPublicKey(): Promise { + const result = await request('/businessDataSources/password/publicKey', { + method: 'get', + }); + return result.data; +} + +async function encryptPassword(password: string): Promise { + if (!password || password.startsWith('rsa:')) { + return password; + } + if (!window.crypto || !window.crypto.subtle) { + throw new Error('The current browser does not support password encryption'); + } + const publicKey = await fetchPasswordPublicKey(); + const cryptoKey = await window.crypto.subtle.importKey( + 'spki', + base64ToArrayBuffer(publicKey), + { + name: 'RSA-OAEP', + hash: 'SHA-256', + }, + false, + ['encrypt'], + ); + const ciphertext = await window.crypto.subtle.encrypt( + { name: 'RSA-OAEP' }, + cryptoKey, + new TextEncoder().encode(password), + ); + return `rsa:${arrayBufferToBase64(ciphertext)}`; +} + +async function encryptBusinessDataSourcePassword(data: BusinessDataSourceForm): Promise { + return { + ...data, + password: await encryptPassword(data.password), + }; +} + +export async function fetchBusinessDataSources(): Promise { + const result = await request('/businessDataSources', { + method: 'get', + }); + return result.data || []; +} + +export async function registerBusinessDataSource(data: BusinessDataSourceForm): Promise { + const encryptedData = await encryptBusinessDataSourcePassword(data); + const result = await request('/businessDataSources', { + method: 'post', + data: encryptedData, + }); + return result.data; +} + +export async function testBusinessDataSource(data: BusinessDataSourceForm): Promise { + const encryptedData = await encryptBusinessDataSourcePassword(data); + const result = await request('/businessDataSources/test', { + method: 'post', + data: encryptedData, + }); + return result.data; +} + +export async function unregisterBusinessDataSource(name: string): Promise { + const result = await request(`/businessDataSources/${encodeURIComponent(name)}`, { + method: 'delete', + }); + return result.data; +} diff --git a/console/src/main/resources/static/console-fe/src/utils/request.ts b/console/src/main/resources/static/console-fe/src/utils/request.ts index 1634022fe76..fad26fd4b90 100644 --- a/console/src/main/resources/static/console-fe/src/utils/request.ts +++ b/console/src/main/resources/static/console-fe/src/utils/request.ts @@ -54,7 +54,7 @@ const createRequest = (baseURL: string, generalErrorMessage: string = 'Request e error => { if (error.response) { const { status } = error.response; - if (status === 403 || status === 401) { + if (status === 401) { (window as any).globalHistory.replace('/login'); return; } diff --git a/console/src/test/java/org/apache/seata/console/controller/BusinessDataSourceControllerTest.java b/console/src/test/java/org/apache/seata/console/controller/BusinessDataSourceControllerTest.java new file mode 100644 index 00000000000..405d26a60e9 --- /dev/null +++ b/console/src/test/java/org/apache/seata/console/controller/BusinessDataSourceControllerTest.java @@ -0,0 +1,185 @@ +/* + * 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. + */ +package org.apache.seata.console.controller; + +import org.apache.seata.common.result.SingleResult; +import org.apache.seata.console.security.DataSourcePasswordCipher; +import org.apache.seata.console.security.DataSourcePasswordTransportCipher; +import org.apache.seata.mcp.entity.dto.MysqlDataSourceRegisterRequest; +import org.apache.seata.mcp.entity.vo.MysqlDataSourceInfo; +import org.apache.seata.mcp.entity.vo.MysqlDataSourceTestResult; +import org.apache.seata.mcp.service.BusinessDataSourceService; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class BusinessDataSourceControllerTest { + + @Test + void shouldListDataSourcesWithoutSensitiveFields() { + BusinessDataSourceService service = mock(BusinessDataSourceService.class); + BusinessDataSourceController controller = newController(service, true); + MysqlDataSourceInfo info = new MysqlDataSourceInfo(); + info.setName("shopDemo"); + info.setResourceId("business-ds://shopDemo"); + info.setDatabaseName("mcp_shop_demo"); + when(service.getMysqlDataSources()).thenReturn(Collections.singletonList(info)); + + SingleResult> result = controller.listDataSources(); + + assertTrue(result.isSuccess()); + assertEquals("shopDemo", result.getData().get(0).getName()); + } + + @Test + void shouldRegisterDataSourceThroughConsoleApiWithEncryptedPasswordValue() { + BusinessDataSourceService service = mock(BusinessDataSourceService.class); + DataSourcePasswordCipher cipher = new DataSourcePasswordCipher("test-secret-key", true); + BusinessDataSourceController controller = + new BusinessDataSourceController(service, cipher, new DataSourcePasswordTransportCipher()); + MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); + request.setPassword(cipher.encrypt("pwd")); + when(service.registerMysqlDataSource(org.mockito.ArgumentMatchers.any())) + .thenReturn("business-ds://shopDemo"); + + SingleResult result = controller.registerDataSource(request); + + assertTrue(result.isSuccess()); + assertEquals("business-ds://shopDemo", result.getData()); + ArgumentCaptor captor = + ArgumentCaptor.forClass(MysqlDataSourceRegisterRequest.class); + verify(service).registerMysqlDataSource(captor.capture()); + assertEquals("pwd", captor.getValue().getPassword()); + } + + @Test + void shouldExposePasswordPublicKey() { + BusinessDataSourceService service = mock(BusinessDataSourceService.class); + BusinessDataSourceController controller = newController(service, true); + + SingleResult result = controller.getPasswordPublicKey(); + + assertTrue(result.isSuccess()); + assertTrue(result.getData().length() > 0); + } + + @Test + void shouldRegisterDataSourceThroughConsoleApiWithTransportEncryptedPasswordValue() { + BusinessDataSourceService service = mock(BusinessDataSourceService.class); + DataSourcePasswordTransportCipher transportCipher = new DataSourcePasswordTransportCipher(); + BusinessDataSourceController controller = new BusinessDataSourceController( + service, new DataSourcePasswordCipher("test-secret-key", false), transportCipher); + MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); + request.setPassword(transportCipher.encrypt("pwd")); + when(service.registerMysqlDataSource(org.mockito.ArgumentMatchers.any())) + .thenReturn("business-ds://shopDemo"); + + SingleResult result = controller.registerDataSource(request); + + assertTrue(result.isSuccess()); + ArgumentCaptor captor = + ArgumentCaptor.forClass(MysqlDataSourceRegisterRequest.class); + verify(service).registerMysqlDataSource(captor.capture()); + assertEquals("pwd", captor.getValue().getPassword()); + } + + @Test + void shouldReturnFailureWhenPasswordCannotBeDecrypted() { + BusinessDataSourceService service = mock(BusinessDataSourceService.class); + BusinessDataSourceController controller = newController(service, true); + MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); + request.setPassword("pwd"); + + SingleResult result = controller.registerDataSource(request); + + assertFalse(result.isSuccess()); + assertEquals("Unable to decrypt datasource password", result.getMessage()); + } + + @Test + void shouldAllowPlainPasswordWhenEncryptionDisabled() { + BusinessDataSourceService service = mock(BusinessDataSourceService.class); + BusinessDataSourceController controller = newController(service, false); + MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); + request.setPassword("pwd"); + when(service.registerMysqlDataSource(request)).thenReturn("business-ds://shopDemo"); + + SingleResult result = controller.registerDataSource(request); + + assertTrue(result.isSuccess()); + assertEquals("business-ds://shopDemo", result.getData()); + verify(service).registerMysqlDataSource(request); + } + + @Test + void shouldReturnFailureWhenRegisterFails() { + BusinessDataSourceService service = mock(BusinessDataSourceService.class); + BusinessDataSourceController controller = newController(service, true); + MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); + when(service.registerMysqlDataSource(request)).thenThrow(new IllegalArgumentException("bad datasource")); + + SingleResult result = controller.registerDataSource(request); + + assertFalse(result.isSuccess()); + assertEquals("bad datasource", result.getMessage()); + } + + @Test + void shouldTestDataSourceThroughConsoleApi() { + BusinessDataSourceService service = mock(BusinessDataSourceService.class); + BusinessDataSourceController controller = newController(service, true); + MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); + MysqlDataSourceTestResult testResult = new MysqlDataSourceTestResult(); + testResult.setSuccess(true); + when(service.testMysqlDataSource(request)).thenReturn(testResult); + + SingleResult result = controller.testDataSource(request); + + assertTrue(result.isSuccess()); + assertTrue(result.getData().isSuccess()); + } + + @Test + void shouldUnregisterDataSourceThroughConsoleApi() { + BusinessDataSourceService service = mock(BusinessDataSourceService.class); + BusinessDataSourceController controller = newController(service, true); + when(service.unregisterMysqlDataSource("shopDemo")).thenReturn("business-ds://shopDemo"); + + SingleResult result = controller.unregisterDataSource("shopDemo"); + + assertTrue(result.isSuccess()); + assertEquals("business-ds://shopDemo", result.getData()); + verify(service).unregisterMysqlDataSource("shopDemo"); + } + + private BusinessDataSourceController newController( + BusinessDataSourceService service, boolean passwordCipherEnabled) { + return new BusinessDataSourceController( + service, + new DataSourcePasswordCipher("test-secret-key", passwordCipherEnabled), + new DataSourcePasswordTransportCipher()); + } +} diff --git a/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java b/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java new file mode 100644 index 00000000000..d2bac5babba --- /dev/null +++ b/console/src/test/java/org/apache/seata/mcp/core/props/BusinessDataSourcesPropertiesTest.java @@ -0,0 +1,216 @@ +/* + * 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. + */ +package org.apache.seata.mcp.core.props; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.seata.mcp.core.secret.EnvSecretResolver; +import org.apache.seata.mcp.entity.dto.MysqlDataSourceRegisterRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class BusinessDataSourcesPropertiesTest { + + @BeforeEach + void setUp() { + BusinessDataSourcesProperties.clear(); + } + + @AfterEach + void tearDown() { + BusinessDataSourcesProperties.clear(); + } + + @Test + void shouldRejectDynamicRegistrationWhenDisabledByDefault() { + BusinessDataSourcesProperties properties = + newProperties(new MockEnvironment().withProperty("MYSQL_PASS", "pwd")); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> properties.registerMysqlDataSource(request("biz", "localhost"))); + + assertEquals("Dynamic business data source registration is disabled", exception.getMessage()); + } + + @Test + void shouldResolvePlainPasswordAndRegisterMysqlResourceId() { + BusinessDataSourcesProperties properties = newProperties(enabledEnv()); + MysqlDataSourceRegisterRequest request = request("biz", "localhost"); + request.setPassword("pwd"); + request.setPasswordSecretRef(""); + + String resourceId = properties.registerMysqlDataSource(request); + + assertEquals("business-ds://biz", resourceId); + BusinessDataSourcesProperties.DataSourceProperties props = + BusinessDataSourcesProperties.getDatasources().get(resourceId); + assertEquals("pwd", props.getPassword()); + assertEquals("app", props.getDatabaseName()); + assertEquals("jdbc:mysql://localhost:3306", props.getUrl()); + assertEquals( + "business-ds://biz", + BusinessDataSourcesProperties.getDataSourcesNamesAndResourceIds() + .get("biz")); + assertEquals(1, properties.getMysqlDataSourceInfos().size()); + } + + @Test + void shouldResolvePasswordSecretRefAndRegisterMysqlResourceId() { + BusinessDataSourcesProperties properties = newProperties(enabledEnv().withProperty("MYSQL_PASS", "pwd")); + + String resourceId = properties.registerMysqlDataSource(request("biz", "localhost")); + + assertEquals("business-ds://biz", resourceId); + BusinessDataSourcesProperties.DataSourceProperties props = + BusinessDataSourcesProperties.getDatasources().get(resourceId); + assertEquals("pwd", props.getPassword()); + assertEquals("MYSQL_PASS", props.getPasswordSecretRef()); + assertEquals("app", props.getDatabaseName()); + assertEquals( + "business-ds://biz", + BusinessDataSourcesProperties.getDataSourcesNamesAndResourceIds() + .get("biz")); + assertEquals(1, properties.getMysqlDataSourceInfos().size()); + } + + @Test + void shouldRequireHostAllowlistForDynamicRegistration() { + MockEnvironment env = new MockEnvironment() + .withProperty("seata.businessDataSources.dynamic-registration.enabled", "true") + .withProperty("MYSQL_PASS", "pwd"); + BusinessDataSourcesProperties properties = newProperties(env); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> properties.registerMysqlDataSource(request("biz", "localhost"))); + + assertEquals("MySQL host allowlist cannot be empty for dynamic registration", exception.getMessage()); + } + + @Test + void shouldRequirePasswordSecretRefForDynamicRegistration() { + BusinessDataSourcesProperties properties = newProperties(enabledEnv()); + MysqlDataSourceRegisterRequest request = request("biz", "localhost"); + request.setPasswordSecretRef(""); + + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> properties.registerMysqlDataSource(request)); + + assertEquals("passwordSecretRef cannot be empty", exception.getMessage()); + } + + @Test + void shouldRejectNonMysqlJdbcUrl() { + BusinessDataSourcesProperties properties = newProperties(enabledEnv().withProperty("MYSQL_PASS", "pwd")); + MysqlDataSourceRegisterRequest request = request("biz", "localhost"); + request.setUrl("jdbc:postgresql://localhost:5432/app"); + + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> properties.registerMysqlDataSource(request)); + + assertEquals("Only jdbc:mysql:// URL is supported", exception.getMessage()); + } + + @Test + void shouldRejectMysqlJdbcUrlWithoutDatabaseName() { + BusinessDataSourcesProperties properties = newProperties(enabledEnv().withProperty("MYSQL_PASS", "pwd")); + MysqlDataSourceRegisterRequest request = request("biz", "localhost"); + request.setUrl("jdbc:mysql://localhost:3306"); + + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> properties.registerMysqlDataSource(request)); + + assertEquals("MySQL JDBC URL must include a database name", exception.getMessage()); + } + + @Test + void shouldRejectSystemDatabaseInMysqlJdbcUrl() { + BusinessDataSourcesProperties properties = newProperties(enabledEnv().withProperty("MYSQL_PASS", "pwd")); + MysqlDataSourceRegisterRequest request = request("biz", "localhost"); + request.setUrl("jdbc:mysql://localhost:3306/information_schema"); + + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> properties.registerMysqlDataSource(request)); + + assertEquals("MySQL JDBC URL database is not allowed: information_schema", exception.getMessage()); + } + + @Test + void shouldApplyHostAllowlist() { + MockEnvironment env = enabledEnv() + .withProperty("MYSQL_PASS", "pwd") + .withProperty("seata.businessDataSources.dynamic-registration.allowed-hosts", "db.example.com"); + BusinessDataSourcesProperties properties = newProperties(env); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> properties.registerMysqlDataSource(request("biz", "localhost"))); + + assertEquals("MySQL host is not allowed: localhost", exception.getMessage()); + assertEquals("business-ds://biz2", properties.registerMysqlDataSource(request("biz2", "db.example.com"))); + } + + @Test + void shouldRejectDuplicateDataSourceName() { + BusinessDataSourcesProperties properties = newProperties(enabledEnv().withProperty("MYSQL_PASS", "pwd")); + + properties.registerMysqlDataSource(request("biz", "localhost")); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> properties.registerMysqlDataSource(request("biz", "localhost"))); + + assertEquals("The data source name has already been registered: biz", exception.getMessage()); + } + + @Test + void shouldUnregisterDynamicDataSourceAndCleanConfig() { + BusinessDataSourcesProperties properties = newProperties(enabledEnv().withProperty("MYSQL_PASS", "pwd")); + properties.registerMysqlDataSource(request("biz", "localhost")); + + String resourceId = properties.unregisterMysqlDataSource("biz"); + + assertEquals("business-ds://biz", resourceId); + assertFalse(BusinessDataSourcesProperties.getDatasources().containsKey(resourceId)); + assertFalse(BusinessDataSourcesProperties.getDataSourcesNamesAndResourceIds() + .containsKey("biz")); + assertFalse(BusinessDataSourcesProperties.getDynamicResourceIds().contains(resourceId)); + } + + private BusinessDataSourcesProperties newProperties(MockEnvironment env) { + return new BusinessDataSourcesProperties(env, new ObjectMapper(), new EnvSecretResolver(env)); + } + + private MockEnvironment enabledEnv() { + return new MockEnvironment() + .withProperty("seata.businessDataSources.dynamic-registration.enabled", "true") + .withProperty("seata.businessDataSources.dynamic-registration.allowed-hosts", "localhost"); + } + + private MysqlDataSourceRegisterRequest request(String name, String host) { + MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); + request.setName(name); + request.setUrl("jdbc:mysql://" + host + ":3306/app"); + request.setUsername("readonly"); + request.setPasswordSecretRef("MYSQL_PASS"); + request.setMinConn(1); + request.setMaxConn(2); + return request; + } +} diff --git a/console/src/test/java/org/apache/seata/mcp/service/MysqlMetadataServiceTest.java b/console/src/test/java/org/apache/seata/mcp/service/MysqlMetadataServiceTest.java new file mode 100644 index 00000000000..3e1b30c889e --- /dev/null +++ b/console/src/test/java/org/apache/seata/mcp/service/MysqlMetadataServiceTest.java @@ -0,0 +1,77 @@ +/* + * 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. + */ +package org.apache.seata.mcp.service; + +import org.apache.seata.common.exception.StoreException; +import org.apache.seata.mcp.core.constant.SqlConstant; +import org.apache.seata.mcp.core.props.BusinessDataSourcesProperties; +import org.apache.seata.mcp.entity.vo.BusinessQueryResult; +import org.apache.seata.mcp.store.SqlExecutionTemplate; +import org.apache.seata.mcp.store.SqlSafetyValidator; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class MysqlMetadataServiceTest { + + @Test + void shouldQueryTablesWithUrlDatabaseName() { + SqlExecutionTemplate sqlExecutionTemplate = mock(SqlExecutionTemplate.class); + BusinessDataSourcesProperties properties = mock(BusinessDataSourcesProperties.class); + BusinessQueryResult result = result(row("TABLE_NAME", "orders", "TABLE_COMMENT", "business orders")); + when(properties.getDatabaseName("business-ds://biz")).thenReturn("app"); + when(sqlExecutionTemplate.trustedQuery("business-ds://biz", SqlConstant.GET_TABLE_NAME_SQL, "app")) + .thenReturn(result); + + MysqlMetadataService service = + new MysqlMetadataService(sqlExecutionTemplate, new SqlSafetyValidator(), properties); + + assertEquals("orders", service.listTables("business-ds://biz").get(0).getTableName()); + verify(sqlExecutionTemplate).trustedQuery("business-ds://biz", SqlConstant.GET_TABLE_NAME_SQL, "app"); + } + + @Test + void shouldRejectExplainForUnsafeSql() { + MysqlMetadataService service = new MysqlMetadataService( + mock(SqlExecutionTemplate.class), new SqlSafetyValidator(), mock(BusinessDataSourcesProperties.class)); + + assertThrows(StoreException.class, () -> service.explainSql("business-ds://biz", "delete from users")); + } + + private BusinessQueryResult result(Map row) { + BusinessQueryResult result = new BusinessQueryResult(); + result.setRows(Collections.singletonList(row)); + result.setRowCount(1); + return result; + } + + private Map row(Object... values) { + Map row = new LinkedHashMap<>(); + for (int i = 0; i < values.length; i += 2) { + row.put(String.valueOf(values[i]), values[i + 1]); + } + return row; + } +} diff --git a/console/src/test/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImplTest.java b/console/src/test/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImplTest.java new file mode 100644 index 00000000000..cfa7f8574d3 --- /dev/null +++ b/console/src/test/java/org/apache/seata/mcp/service/impl/BusinessDataSourceServiceImplTest.java @@ -0,0 +1,148 @@ +/* + * 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. + */ +package org.apache.seata.mcp.service.impl; + +import org.apache.seata.mcp.core.props.BusinessDataSourcesProperties; +import org.apache.seata.mcp.entity.dto.MysqlDataSourceRegisterRequest; +import org.apache.seata.mcp.entity.vo.BusinessQueryResult; +import org.apache.seata.mcp.entity.vo.MysqlDataSourceTestResult; +import org.apache.seata.mcp.service.MysqlMetadataService; +import org.apache.seata.mcp.store.DataSourceFactory; +import org.apache.seata.mcp.store.SqlExecutionTemplate; +import org.apache.seata.mcp.store.SqlSafetyValidator; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Method; +import java.sql.SQLException; +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class BusinessDataSourceServiceImplTest { + + @Test + void shouldRemovePoolWhenUnregisteringDynamicDataSource() { + SqlExecutionTemplate sqlExecutionTemplate = mock(SqlExecutionTemplate.class); + BusinessDataSourcesProperties properties = mock(BusinessDataSourcesProperties.class); + MysqlMetadataService metadataService = mock(MysqlMetadataService.class); + BusinessDataSourceServiceImpl service = new BusinessDataSourceServiceImpl( + sqlExecutionTemplate, properties, metadataService, new SqlSafetyValidator()); + when(properties.unregisterMysqlDataSource("biz")).thenReturn("business-ds://biz"); + + try (MockedStatic dataSourceFactory = mockStatic(DataSourceFactory.class)) { + String resourceId = service.unregisterMysqlDataSource("biz"); + + assertEquals("business-ds://biz", resourceId); + dataSourceFactory.verify(() -> DataSourceFactory.removeDataSource("business-ds://biz")); + } + verify(properties).unregisterMysqlDataSource("biz"); + } + + @Test + void shouldQueryTableWithinUrlDatabase() { + SqlExecutionTemplate sqlExecutionTemplate = mock(SqlExecutionTemplate.class); + BusinessDataSourcesProperties properties = mock(BusinessDataSourcesProperties.class); + MysqlMetadataService metadataService = mock(MysqlMetadataService.class); + BusinessQueryResult result = new BusinessQueryResult(); + BusinessDataSourceServiceImpl service = new BusinessDataSourceServiceImpl( + sqlExecutionTemplate, properties, metadataService, new SqlSafetyValidator()); + when(properties.getDatabaseName("business-ds://biz")).thenReturn("app"); + when(sqlExecutionTemplate.queryWithMaxRows("business-ds://biz", "SELECT `id`, `name` FROM `app`.`users`", 10)) + .thenReturn(result); + + BusinessQueryResult actual = service.queryMysqlTable( + "business-ds://biz", "users", Arrays.asList("id", "name"), Collections.emptyMap(), 10); + + assertEquals(result, actual); + verify(sqlExecutionTemplate) + .queryWithMaxRows("business-ds://biz", "SELECT `id`, `name` FROM `app`.`users`", 10); + } + + @Test + void shouldExposeValidationMessageWhenTestingDataSource() { + SqlExecutionTemplate sqlExecutionTemplate = mock(SqlExecutionTemplate.class); + BusinessDataSourcesProperties properties = mock(BusinessDataSourcesProperties.class); + MysqlMetadataService metadataService = mock(MysqlMetadataService.class); + BusinessDataSourceServiceImpl service = new BusinessDataSourceServiceImpl( + sqlExecutionTemplate, properties, metadataService, new SqlSafetyValidator()); + MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); + when(properties.buildDynamicMysqlProperties(request)) + .thenThrow(new IllegalArgumentException("MySQL host is not allowed: 127.0.0.1")); + + MysqlDataSourceTestResult result = service.testMysqlDataSource(request); + + assertEquals("MySQL host is not allowed: 127.0.0.1", result.getMessage()); + } + + @Test + void shouldReturnSanitizedExceptionWhenTestingDataSource() { + SqlExecutionTemplate sqlExecutionTemplate = mock(SqlExecutionTemplate.class); + BusinessDataSourcesProperties properties = mock(BusinessDataSourcesProperties.class); + MysqlMetadataService metadataService = mock(MysqlMetadataService.class); + BusinessDataSourceServiceImpl service = new BusinessDataSourceServiceImpl( + sqlExecutionTemplate, properties, metadataService, new SqlSafetyValidator()); + MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); + request.setUrl("jdbc:mysql://localhost:3306/app"); + request.setUsername("root"); + request.setPassword("secret"); + RuntimeException exception = new RuntimeException( + "Access denied for user root using password=secret at jdbc:mysql://localhost:3306/app"); + when(properties.buildDynamicMysqlProperties(request)).thenThrow(exception); + + MysqlDataSourceTestResult result = service.testMysqlDataSource(request); + + assertFalse(result.isSuccess()); + assertTrue(result.getMessage().contains("RuntimeException")); + assertFalse(result.getMessage().contains("jdbc:mysql://localhost:3306/app")); + assertFalse(result.getMessage().contains("root")); + assertFalse(result.getMessage().contains("secret")); + } + + @Test + void shouldIncludeSqlStateAndErrorCodeInSanitizedConnectionMessage() throws Exception { + SqlExecutionTemplate sqlExecutionTemplate = mock(SqlExecutionTemplate.class); + BusinessDataSourcesProperties properties = mock(BusinessDataSourcesProperties.class); + MysqlMetadataService metadataService = mock(MysqlMetadataService.class); + BusinessDataSourceServiceImpl service = new BusinessDataSourceServiceImpl( + sqlExecutionTemplate, properties, metadataService, new SqlSafetyValidator()); + MysqlDataSourceRegisterRequest request = new MysqlDataSourceRegisterRequest(); + request.setUrl("jdbc:mysql://localhost:3306/app"); + request.setUsername("root"); + request.setPassword("secret"); + SQLException exception = new SQLException( + "Access denied for user root using password=secret at jdbc:mysql://localhost:3306/app", "28000", 1045); + Method method = BusinessDataSourceServiceImpl.class.getDeclaredMethod( + "sanitizeConnectionTestError", Exception.class, MysqlDataSourceRegisterRequest.class); + method.setAccessible(true); + + String message = (String) method.invoke(service, exception, request); + + assertTrue(message.contains("SQLState: 28000")); + assertTrue(message.contains("ErrorCode: 1045")); + assertFalse(message.contains("jdbc:mysql://localhost:3306/app")); + assertFalse(message.contains("root")); + assertFalse(message.contains("secret")); + } +} diff --git a/console/src/test/java/org/apache/seata/mcp/store/SqlExecutionTemplateTest.java b/console/src/test/java/org/apache/seata/mcp/store/SqlExecutionTemplateTest.java new file mode 100644 index 00000000000..1f2b8455222 --- /dev/null +++ b/console/src/test/java/org/apache/seata/mcp/store/SqlExecutionTemplateTest.java @@ -0,0 +1,164 @@ +/* + * 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. + */ +package org.apache.seata.mcp.store; + +import org.apache.seata.common.exception.StoreException; +import org.apache.seata.mcp.entity.vo.BusinessQueryResult; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springframework.test.util.ReflectionTestUtils; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SqlExecutionTemplateTest { + + @Test + void shouldExecuteSelectQueryWithStructuredResult() throws Exception { + DataSource dataSource = mock(DataSource.class); + Connection connection = mock(Connection.class); + PreparedStatement preparedStatement = mock(PreparedStatement.class); + ResultSet resultSet = mock(ResultSet.class); + ResultSetMetaData metaData = mock(ResultSetMetaData.class); + String sql = "select name from users where id = ?"; + + when(dataSource.getConnection()).thenReturn(connection); + when(connection.prepareStatement(sql)).thenReturn(preparedStatement); + when(preparedStatement.executeQuery()).thenReturn(resultSet); + when(resultSet.getMetaData()).thenReturn(metaData); + when(metaData.getColumnCount()).thenReturn(1); + when(metaData.getColumnLabel(1)).thenReturn("name"); + when(resultSet.next()).thenReturn(true, false); + when(resultSet.getObject(1)).thenReturn("Alice"); + + try (MockedStatic dataSourceFactory = mockStatic(DataSourceFactory.class)) { + dataSourceFactory.when(() -> DataSourceFactory.getDataSource("biz")).thenReturn(dataSource); + + BusinessQueryResult result = new SqlExecutionTemplate(new SqlSafetyValidator()).query("biz", sql, 1); + + assertEquals("biz", result.getResourceId()); + assertEquals(1, result.getColumns().size()); + assertEquals("name", result.getColumns().get(0)); + assertEquals(1, result.getRowCount()); + assertEquals("Alice", result.getRows().get(0).get("name")); + } + verify(connection).setReadOnly(true); + verify(preparedStatement).setQueryTimeout(30); + verify(preparedStatement).setFetchSize(100); + verify(preparedStatement).setMaxRows(501); + verify(preparedStatement).setObject(1, 1); + } + + @Test + void shouldReturnTruncatedWhenRowsExceedMaxRows() throws Exception { + DataSource dataSource = mock(DataSource.class); + Connection connection = mock(Connection.class); + PreparedStatement preparedStatement = mock(PreparedStatement.class); + ResultSet resultSet = mock(ResultSet.class); + ResultSetMetaData metaData = mock(ResultSetMetaData.class); + String sql = "select name from users"; + SqlExecutionTemplate template = new SqlExecutionTemplate(new SqlSafetyValidator()); + ReflectionTestUtils.setField(template, "maxRows", 1); + + when(dataSource.getConnection()).thenReturn(connection); + when(connection.prepareStatement(sql)).thenReturn(preparedStatement); + when(preparedStatement.executeQuery()).thenReturn(resultSet); + when(resultSet.getMetaData()).thenReturn(metaData); + when(metaData.getColumnCount()).thenReturn(1); + when(metaData.getColumnLabel(1)).thenReturn("name"); + when(resultSet.next()).thenReturn(true, true, false); + when(resultSet.getObject(1)).thenReturn("Alice"); + + try (MockedStatic dataSourceFactory = mockStatic(DataSourceFactory.class)) { + dataSourceFactory.when(() -> DataSourceFactory.getDataSource("biz")).thenReturn(dataSource); + + BusinessQueryResult result = template.query("biz", sql); + + assertEquals(1, result.getRowCount()); + assertTrue(result.isTruncated()); + assertEquals(1, result.getMaxRows()); + } + verify(preparedStatement).setMaxRows(2); + } + + @Test + void shouldRejectDmlAndDdlSql() { + SqlExecutionTemplate template = new SqlExecutionTemplate(new SqlSafetyValidator()); + + assertThrows(StoreException.class, () -> template.query("biz", "delete from users where id = 1")); + assertThrows(StoreException.class, () -> template.query("biz", "drop table users")); + } + + @Test + void shouldRejectMultipleStatements() { + SqlExecutionTemplate template = new SqlExecutionTemplate(new SqlSafetyValidator()); + + StoreException exception = + assertThrows(StoreException.class, () -> template.query("biz", "select 1; select 2")); + + assertEquals("Only a single SELECT statement is allowed", exception.getMessage()); + } + + @Test + void shouldRejectSelectForUpdate() { + SqlExecutionTemplate template = new SqlExecutionTemplate(new SqlSafetyValidator()); + + StoreException exception = + assertThrows(StoreException.class, () -> template.query("biz", "select * from users for update")); + + assertEquals("SELECT locking clauses are not allowed", exception.getMessage()); + } + + @Test + void shouldRejectUndoLogQueryCaseInsensitive() { + SqlExecutionTemplate template = new SqlExecutionTemplate(new SqlSafetyValidator()); + + StoreException exception = + assertThrows(StoreException.class, () -> template.query("biz", "select * from `Undo_Log`")); + + assertEquals("Querying undo_log is not allowed", exception.getMessage()); + } + + @Test + void shouldRejectCrossDatabaseSelect() { + SqlSafetyValidator validator = new SqlSafetyValidator(); + + StoreException exception = assertThrows( + StoreException.class, () -> validator.validateMysqlSelect("select * from other_db.users", "app")); + + assertEquals("Cross-database query is not allowed", exception.getMessage()); + } + + @Test + void shouldAllowDefaultOrSameDatabaseSelect() { + SqlSafetyValidator validator = new SqlSafetyValidator(); + + validator.validateMysqlSelect("select * from users", "app"); + validator.validateMysqlSelect("select * from app.users", "app"); + } +} diff --git a/console/src/test/java/org/apache/seata/mcp/tools/BusinessDataSourceToolsTest.java b/console/src/test/java/org/apache/seata/mcp/tools/BusinessDataSourceToolsTest.java new file mode 100644 index 00000000000..9b8b2d185ac --- /dev/null +++ b/console/src/test/java/org/apache/seata/mcp/tools/BusinessDataSourceToolsTest.java @@ -0,0 +1,73 @@ +/* + * 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. + */ +package org.apache.seata.mcp.tools; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.seata.mcp.entity.vo.MysqlDataSourceInfo; +import org.apache.seata.mcp.service.BusinessDataSourceService; +import org.junit.jupiter.api.Test; +import org.springaicommunity.mcp.annotation.McpTool; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class BusinessDataSourceToolsTest { + + @Test + void shouldExposeToolNamesWithoutMysqlPrefix() { + Set toolNames = Arrays.stream(BusinessDataSourceTools.class.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(McpTool.class)) + .map(this::toolName) + .collect(Collectors.toSet()); + + assertEquals( + new HashSet<>(Arrays.asList( + "getDataSources", "getTableNames", "getTableSchema", "queryTable", "explainSql", "runSql")), + toolNames); + } + + @Test + void shouldSerializeDataSourceResourceWithoutSensitiveFields() { + BusinessDataSourceService service = mock(BusinessDataSourceService.class); + BusinessDataSourceTools tools = new BusinessDataSourceTools(service, new ObjectMapper()); + MysqlDataSourceInfo info = new MysqlDataSourceInfo(); + info.setName("biz"); + info.setResourceId("business-ds://biz"); + info.setDatasource("druid"); + when(service.getMysqlDataSources()).thenReturn(Collections.singletonList(info)); + + String json = tools.mysqlDataSourcesResource(); + + assertFalse(json.contains("jdbc:mysql://")); + assertFalse(json.contains("username")); + assertFalse(json.contains("password")); + } + + private String toolName(Method method) { + McpTool annotation = method.getAnnotation(McpTool.class); + return annotation.name().isEmpty() ? method.getName() : annotation.name(); + } +} diff --git a/dependencies/pom.xml b/dependencies/pom.xml index 23a312cbe31..52393adb3f5 100644 --- a/dependencies/pom.xml +++ b/dependencies/pom.xml @@ -124,7 +124,7 @@ 4.8 ${mysql.version} - 8.0.27 + 8.0.33 5.0.0 @@ -303,6 +303,11 @@ mysql-connector-java ${mysql.version} + + com.mysql + mysql-connector-j + ${mysql8.version} + org.postgresql postgresql diff --git a/namingserver/pom.xml b/namingserver/pom.xml index 7359e004c36..4dafea9eaa3 100644 --- a/namingserver/pom.xml +++ b/namingserver/pom.xml @@ -169,6 +169,14 @@ org.apache.commons commons-lang3 + + com.squareup.okhttp3 + okhttp + + + com.mysql + mysql-connector-j + diff --git a/namingserver/src/main/resources/application.yml b/namingserver/src/main/resources/application.yml index 1ad1193cf9b..5844c7430c6 100644 --- a/namingserver/src/main/resources/application.yml +++ b/namingserver/src/main/resources/application.yml @@ -57,9 +57,48 @@ seata: security: secretKey: SeataSecretKey0c382ef121d778043159209298fd40bf3850a017 tokenValidityInMilliseconds: 14400000 - csrf-ignore-urls: /naming/v1/**,/api/v1/naming/** + csrf-ignore-urls: /naming/v1/**,/api/v1/naming/**,/api/v1/businessDataSources/** ignore: urls: /,/**/*.css,/**/*.js,/**/*.html,/**/*.map,/**/*.svg,/**/*.png,/**/*.jpeg,/**/*.ico,/**/*.woff,/**/*.woff2,/**/*.ttf,/api/v1/auth/login,/version.json,/naming/v1/health,/error,/saga-statemachine-designer/** + + + # MCP Server Configuration + mcp: + # Maximum query time interval, The unit is milliseconds, Default one day + query: + max-query-duration: 86400000 + + # Business data source configuration + businessDataSources: + encryption: + enabled: true + dynamic-registration: + enabled: true + allowed-hosts: localhost + dataSource1: + # Whether this data source is enabled + enabled: true + dbType: mysql + driverClassName: com.mysql.cj.jdbc.Driver # Currently, only mysql is supported + url: + username: root + password: + datasource: druid # Optional connection pool type (druid/hikari/dbcp) + minConn: 5 + maxConn: 50 + maxWait: 5000 + dataSource2: + enabled: true + dbType: mysql + driverClassName: com.mysql.cj.jdbc.Driver + url: + username: root + password: + datasource: dbcp + minConn: 5 + maxConn: 50 + maxWait: 5000 + management: endpoints: web: @@ -71,8 +110,3 @@ management: distribution: percentiles: http.server.requests: 0.5, 0.99, 0.999 - # MCP Server Configuration - mcp: - # Maximum query time interval, The unit is milliseconds, Default one day - query: - max-query-duration: 86400000