diff --git a/dbaas-client/dbaas-client-java/dbaas-client-base/pom.xml b/dbaas-client/dbaas-client-java/dbaas-client-base/pom.xml index 56bbc8115..168c60e79 100644 --- a/dbaas-client/dbaas-client-java/dbaas-client-base/pom.xml +++ b/dbaas-client/dbaas-client-java/dbaas-client-base/pom.xml @@ -37,5 +37,9 @@ com.fasterxml.jackson.core jackson-annotations + + com.fasterxml.jackson.core + jackson-databind + diff --git a/dbaas-client/dbaas-client-java/dbaas-client-base/src/main/java/com/netcracker/cloud/dbaas/client/management/DatabasePool.java b/dbaas-client/dbaas-client-java/dbaas-client-base/src/main/java/com/netcracker/cloud/dbaas/client/management/DatabasePool.java index b727dad7a..ac72a6ca0 100644 --- a/dbaas-client/dbaas-client-java/dbaas-client-base/src/main/java/com/netcracker/cloud/dbaas/client/management/DatabasePool.java +++ b/dbaas-client/dbaas-client-java/dbaas-client-base/src/main/java/com/netcracker/cloud/dbaas/client/management/DatabasePool.java @@ -1,12 +1,16 @@ package com.netcracker.cloud.dbaas.client.management; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; import com.netcracker.cloud.dbaas.client.DbaasClient; import com.netcracker.cloud.dbaas.client.DbaasConst; import com.netcracker.cloud.dbaas.client.entity.database.AbstractConnectorSettings; import com.netcracker.cloud.dbaas.client.entity.database.AbstractDatabase; import com.netcracker.cloud.dbaas.client.entity.database.type.DatabaseType; import com.netcracker.cloud.dbaas.client.service.LogicalDbProvider; +import com.netcracker.cloud.dbaas.client.service.mountedsecret.MountedSecretSource; +import com.netcracker.cloud.dbaas.client.service.mountedsecret.SecretMetadata; import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; @@ -31,6 +35,22 @@ public class DatabasePool { private final Comparator postConnectProcessorsOrder; private List dbProviders; private Map>, DatabaseClientCreator> mapDatabaseClientCreators = new ConcurrentHashMap<>(); + + /** + * Reads connection properties from Secrets mounted at {@code /etc/secrets/dbaas-secrets}, + * consulted before the REST call in {@link #createDatabase}. Always registered; when nothing is + * mounted it returns empty and the pool falls back to REST exactly as before. + */ + private MountedSecretSource mountedSecretSource = new MountedSecretSource(); + + /** + * Used to build a typed {@link AbstractDatabase} from a mounted Secret via the synthetic-response + * mechanism (a property map converted to {@code DatabaseType#getDatabaseClass()}). Unknown + * connection-property keys (e.g. {@code roHost}) are tolerated, matching the REST deserialization. + */ + private final ObjectMapper objectMapper = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + /** * L1 cache holds cached databases connections. When client asks for a database, we first look in L1 cache. * Databases in this cache are ready-for-use, their post-processors have been already successfully applied. @@ -178,6 +198,12 @@ protected > D createDatabase(DatabaseKey return logDb; } + D mountedDb = getDbFromMountedSecret(classifier, databaseConfig, key.getDbType()); + if (mountedDb != null) { + log.debug("Logical database was obtained from mounted secret. Classifier: {}, type {}", classifier, key.getDbType()); + return mountedDb; + } + databaseDefinitionHandler.applyDefinitionProcess(key.getDbType(), databaseConfig, classifier, namespace); return dbaasClient.getOrCreateDatabase( key.getDbType(), @@ -186,6 +212,53 @@ protected > D createDatabase(DatabaseKey databaseConfig); } + private > D getDbFromMountedSecret(Map classifier, + DatabaseConfig databaseConfig, + DatabaseType type) { + String role = databaseConfig != null ? databaseConfig.getUserRole() : null; + return mountedSecretSource.resolve(classifier, type.getName(), role) + .map(resolved -> buildAbstractDatabase(type, classifier, resolved)) + .orElse(null); + } + + /** + * Builds the typed database from a mounted Secret (synthetic-response): assemble a map mirroring + * the dbaas REST response and convert it to {@code type.getDatabaseClass()} with the same + * deserialization semantics as the REST path. No provisioning and no REST call happen here. + */ + private > D buildAbstractDatabase(DatabaseType type, + Map classifier, + MountedSecretSource.Resolved resolved) { + SecretMetadata meta = resolved.metadata(); + Map synthetic = new HashMap<>(); + synthetic.put("classifier", meta.getClassifier() != null ? meta.getClassifier() : classifier); + synthetic.put("connectionProperties", resolved.connectionProperties()); + + String name = meta.getName() != null ? meta.getName() : asString(resolved.connectionProperties().get("name")); + if (name != null) { + synthetic.put("name", name); + } + String dbNamespace = meta.getNamespace() != null ? meta.getNamespace() : asString(classifier.get(DbaasConst.NAMESPACE)); + if (dbNamespace != null) { + synthetic.put("namespace", dbNamespace); + } + if (meta.getSettings() != null) { + synthetic.put("settings", meta.getSettings()); + } + return objectMapper.convertValue(synthetic, type.getDatabaseClass()); + } + + private static String asString(Object value) { + return value instanceof String s ? s : null; + } + + /** + * Test seam: package-private so unit tests can point the mounted-secret source at a temp directory. + */ + void setMountedSecretSource(MountedSecretSource mountedSecretSource) { + this.mountedSecretSource = mountedSecretSource; + } + private Comparator getComparator() { return postConnectProcessorsOrder; } diff --git a/dbaas-client/dbaas-client-java/dbaas-client-base/src/main/java/com/netcracker/cloud/dbaas/client/service/mountedsecret/ClassifierMatcher.java b/dbaas-client/dbaas-client-java/dbaas-client-base/src/main/java/com/netcracker/cloud/dbaas/client/service/mountedsecret/ClassifierMatcher.java new file mode 100644 index 000000000..8c9d9f014 --- /dev/null +++ b/dbaas-client/dbaas-client-java/dbaas-client-base/src/main/java/com/netcracker/cloud/dbaas/client/service/mountedsecret/ClassifierMatcher.java @@ -0,0 +1,83 @@ +package com.netcracker.cloud.dbaas.client.service.mountedsecret; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; + +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; + +/** + * Builds the canonical lookup key used to match a request's (classifier, type, role) against a + * mounted Secret's descriptor. Both the client's own classifier and the descriptor's classifier are + * canonicalized by this same code, so they only have to agree with each other — there is no need to + * be byte-identical with any other language client. + *

+ * Canonicalization mirrors the Go provider semantics: keys sorted recursively, {@code scope} + * lower-cased, empty top-level {@code namespace}/{@code tenantId} omitted, and empty nested objects + * dropped. Role matching is exact-string after trim (case-sensitive), and an empty role matches a + * descriptor whose {@code userRole} was left unset. + */ +@Slf4j +final class ClassifierMatcher { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final Object OMIT = new Object(); + + private ClassifierMatcher() { + } + + static String matchingKey(Map classifier, String type, String role) { + return canonical(classifier) + + "|" + (type == null ? "" : type.toLowerCase(Locale.ROOT)) + + "|" + (role == null ? "" : role.strip()); + } + + static String canonical(Map classifier) { + try { + return MAPPER.writeValueAsString(normalizeMap(classifier, true)); + } catch (Exception e) { + log.warn("mounted-secret: failed to canonicalize classifier {}: {}", classifier, e.toString()); + return ""; + } + } + + private static Object normalizeMap(Map map, boolean topLevel) { + TreeMap out = new TreeMap<>(); + if (map != null) { + for (Map.Entry e : map.entrySet()) { + Object normalized = normalizeValue(e.getKey(), e.getValue(), topLevel); + if (normalized != OMIT) { + out.put(e.getKey(), normalized); + } + } + } + // Drop empty nested objects; keep the (possibly empty) top-level object. + if (!topLevel && out.isEmpty()) { + return null; + } + return out; + } + + @SuppressWarnings("unchecked") + private static Object normalizeValue(String key, Object value, boolean topLevel) { + if (value == null) { + return OMIT; + } + if (value instanceof String s) { + if ("scope".equals(key)) { + s = s.toLowerCase(Locale.ROOT); + } + if (topLevel && s.isEmpty() && ("namespace".equals(key) || "tenantId".equals(key))) { + return OMIT; + } + return s; + } + if (value instanceof Map nested) { + Object normalizedNested = normalizeMap((Map) nested, false); + return normalizedNested == null ? OMIT : normalizedNested; + } + // numbers / booleans / arrays are serialized as-is (no recursion into arrays, matching Go). + return value; + } +} diff --git a/dbaas-client/dbaas-client-java/dbaas-client-base/src/main/java/com/netcracker/cloud/dbaas/client/service/mountedsecret/MountedSecretSource.java b/dbaas-client/dbaas-client-java/dbaas-client-base/src/main/java/com/netcracker/cloud/dbaas/client/service/mountedsecret/MountedSecretSource.java new file mode 100644 index 000000000..25c3711e7 --- /dev/null +++ b/dbaas-client/dbaas-client-java/dbaas-client-base/src/main/java/com/netcracker/cloud/dbaas/client/service/mountedsecret/MountedSecretSource.java @@ -0,0 +1,247 @@ +package com.netcracker.cloud.dbaas.client.service.mountedsecret; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Reads database connection properties from Secrets mounted at a fixed path instead of calling + * dbaas over REST. This is the Java port of the Go {@code mountedSecretProvider}. + *

+ * Each mounted Secret is a directory with two keys: + *

    + *
  • {@code metadata.json} — the descriptor ({classifier, type, userRole, id, name, namespace, settings});
  • + *
  • {@code connectionProperties.json} — the raw connection-properties map.
  • + *
+ * A startup scan indexes every descriptor by its canonical {@code (classifier, type, role)} key. + * {@link #resolve} re-reads {@code connectionProperties.json} fresh on every call (so a rotated + * password is picked up at the next refetch), and returns empty on a miss so the caller falls + * through to REST. The path set is fixed by the Deployment, so the index is only refreshed by a + * throttled re-scan on a miss (a new Secret added without a redeploy) — there is no watcher. + */ +@Slf4j +public class MountedSecretSource { + + // Fixed mount path established by the dbaas-operator Deployment contract, not a configurable URI. + @SuppressWarnings("java:S1075") + static final String DEFAULT_PATH = "/etc/secrets/dbaas-secrets"; + static final String METADATA_FILE = "metadata.json"; + static final String CONNECTION_PROPERTIES_FILE = "connectionProperties.json"; + static final Duration RESCAN_THROTTLE = Duration.ofSeconds(60); + + private static final ObjectMapper MAPPER = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + private static final TypeReference> MAP_TYPE = new TypeReference<>() { + }; + + private final Path basePath; + private final Duration rescanThrottle; + private final ReentrantLock rescanLock = new ReentrantLock(); + + private final AtomicReference> index = new AtomicReference<>(Collections.emptyMap()); + private volatile Instant lastRescan = Instant.EPOCH; + + public MountedSecretSource() { + this(DEFAULT_PATH); + } + + public MountedSecretSource(String basePath) { + this(basePath, RESCAN_THROTTLE); + } + + // Package-private: lets tests drive the throttled re-scan deterministically (e.g. Duration.ZERO). + MountedSecretSource(String basePath, Duration rescanThrottle) { + this.basePath = Path.of(basePath); + this.rescanThrottle = rescanThrottle; + buildIndex(); + } + + private record IndexEntry(Path dir, SecretMetadata meta) { + } + + /** The connection properties read from a matched Secret plus its descriptor. */ + public record Resolved(Map connectionProperties, SecretMetadata metadata) { + } + + /** + * Looks up a mounted Secret for {@code (classifier, type, role)} and reads its connection + * properties fresh. Returns empty on a miss — the caller must then fall back to REST. + */ + public Optional resolve(Map classifier, String type, String role) { + String key = ClassifierMatcher.matchingKey(classifier, type, role); + + IndexEntry entry = index.get().get(key); + if (entry == null) { + if (rescanDue()) { + rescanThrottled(); + entry = index.get().get(key); + } + if (entry == null) { + return Optional.empty(); + } + } + + // Re-read the descriptor fresh and confirm it still maps to this key. Guards against a Secret + // whose metadata.json changed in place (classifier/type/role): the cached entry would otherwise + // keep serving the new connectionProperties under the old key. On a mismatch evict and miss so + // the caller falls back to REST and a later re-scan re-indexes the new descriptor. + SecretMetadata meta = freshMetadataFor(entry.dir(), key); + if (meta == null) { + evict(key, entry); + log.warn("mounted-secret: descriptor in {} no longer matches the requested key " + + "(type={}, classifier={}, role={}); evicting and falling back to REST", entry.dir(), type, classifier, role); + return Optional.empty(); + } + + Path propsPath = entry.dir().resolve(CONNECTION_PROPERTIES_FILE); + byte[] data; + try { + data = Files.readAllBytes(propsPath); + } catch (NoSuchFileException e) { + // Secret directory was removed; evict the stale entry so we don't keep hitting it. + evict(key, entry); + log.warn("mounted-secret: secret removed from disk, evicting index entry for {}", entry.dir()); + return Optional.empty(); + } catch (IOException e) { + log.warn("mounted-secret: cannot read {} in {}: {}", CONNECTION_PROPERTIES_FILE, entry.dir(), e.toString()); + return Optional.empty(); + } + + Map props; + try { + props = MAPPER.readValue(data, MAP_TYPE); + } catch (IOException e) { + log.warn("mounted-secret: corrupt {} in {}: {}", CONNECTION_PROPERTIES_FILE, entry.dir(), e.toString()); + return Optional.empty(); + } + + log.debug("mounted-secret: hit for type={} classifier={} role={}", type, classifier, role); + return Optional.of(new Resolved(props, meta)); + } + + /** + * Re-reads {@code metadata.json} for a hit and returns it only if it still maps to + * {@code expectedKey}; returns null if the descriptor is gone, corrupt, incomplete, or changed in + * place so that it no longer matches the requested {@code (classifier, type, role)}. + */ + private SecretMetadata freshMetadataFor(Path dir, String expectedKey) { + SecretMetadata meta; + try { + meta = MAPPER.readValue(Files.readAllBytes(dir.resolve(METADATA_FILE)), SecretMetadata.class); + } catch (IOException e) { + return null; + } + if (meta.getClassifier() == null || meta.getClassifier().isEmpty() + || meta.getType() == null || meta.getType().isEmpty()) { + return null; + } + String currentKey = ClassifierMatcher.matchingKey(meta.getClassifier(), meta.getType(), meta.getUserRole()); + return expectedKey.equals(currentKey) ? meta : null; + } + + private boolean rescanDue() { + return Duration.between(lastRescan, Instant.now()).compareTo(rescanThrottle) >= 0; + } + + private void rescanThrottled() { + // tryLock collapses concurrent misses into a single re-scan (singleflight-style). + if (!rescanLock.tryLock()) { + return; + } + try { + if (rescanDue()) { + buildIndex(); + } + } finally { + rescanLock.unlock(); + } + } + + private void evict(String key, IndexEntry expected) { + rescanLock.lock(); + try { + IndexEntry current = index.get().get(key); + if (current == null || !current.equals(expected)) { + return; + } + Map copy = new HashMap<>(index.get()); + copy.remove(key); + index.set(Collections.unmodifiableMap(copy)); + } finally { + rescanLock.unlock(); + } + } + + private void buildIndex() { + Map newIndex = new HashMap<>(); + try (DirectoryStream dirs = Files.newDirectoryStream(basePath)) { + List secretDirs = new ArrayList<>(); + for (Path dir : dirs) { + if (Files.isDirectory(dir)) { + secretDirs.add(dir); + } + } + // Index in a deterministic order so duplicate-key resolution is stable across restarts and + // re-scans (DirectoryStream order is filesystem-dependent); the lowest directory name wins. + secretDirs.sort(Comparator.comparing(dir -> dir.getFileName().toString())); + for (Path dir : secretDirs) { + indexSecretDir(dir, newIndex); + } + } catch (NoSuchFileException e) { + log.debug("mounted-secret: secret path {} not present, skipping (falling back to REST)", basePath); + } catch (IOException e) { + log.warn("mounted-secret: cannot read secret path {}: {}", basePath, e.toString()); + } + this.index.set(Collections.unmodifiableMap(newIndex)); + this.lastRescan = Instant.now(); + } + + private void indexSecretDir(Path dir, Map newIndex) { + byte[] data; + try { + data = Files.readAllBytes(dir.resolve(METADATA_FILE)); + } catch (IOException e) { + // No metadata.json — not a dbaas secret, skip silently. + return; + } + + SecretMetadata meta; + try { + meta = MAPPER.readValue(data, SecretMetadata.class); + } catch (IOException e) { + log.warn("mounted-secret: corrupt {} in {}: {}", METADATA_FILE, dir, e.toString()); + return; + } + + if (meta.getClassifier() == null || meta.getClassifier().isEmpty() + || meta.getType() == null || meta.getType().isEmpty()) { + log.warn("mounted-secret: incomplete metadata in {} (missing classifier or type), skipping", dir); + return; + } + + String key = ClassifierMatcher.matchingKey(meta.getClassifier(), meta.getType(), meta.getUserRole()); + IndexEntry existing = newIndex.putIfAbsent(key, new IndexEntry(dir, meta)); + if (existing != null) { + log.warn("mounted-secret: duplicate key for {} and {} — keeping {} (lowest directory name wins); " + + "check operator configuration", existing.dir(), dir, existing.dir()); + } + } +} diff --git a/dbaas-client/dbaas-client-java/dbaas-client-base/src/main/java/com/netcracker/cloud/dbaas/client/service/mountedsecret/SecretMetadata.java b/dbaas-client/dbaas-client-java/dbaas-client-base/src/main/java/com/netcracker/cloud/dbaas/client/service/mountedsecret/SecretMetadata.java new file mode 100644 index 000000000..ba3dc0eb5 --- /dev/null +++ b/dbaas-client/dbaas-client-java/dbaas-client-base/src/main/java/com/netcracker/cloud/dbaas/client/service/mountedsecret/SecretMetadata.java @@ -0,0 +1,27 @@ +package com.netcracker.cloud.dbaas.client.service.mountedsecret; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +import java.util.Map; + +/** + * Descriptor that dbaas-operator writes into the {@code metadata.json} key of every Secret it + * materializes for a {@code DatabaseSecretClaim}. It mirrors the operator's {@code secretMetadata} + * struct and the Go client's {@code secretMetadata}. + *

+ * Unknown properties are ignored on purpose so a newer operator (e.g. a future {@code schemaVersion}) + * does not break an older client. + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SecretMetadata { + + private Map classifier; + private String type; + private String userRole; + private String id; + private String name; + private String namespace; + private Map settings; +} diff --git a/dbaas-client/dbaas-client-java/dbaas-client-base/src/test/java/com/netcracker/cloud/dbaas/client/management/DatabasePoolMountedSecretTest.java b/dbaas-client/dbaas-client-java/dbaas-client-base/src/test/java/com/netcracker/cloud/dbaas/client/management/DatabasePoolMountedSecretTest.java new file mode 100644 index 000000000..c6099a644 --- /dev/null +++ b/dbaas-client/dbaas-client-java/dbaas-client-base/src/test/java/com/netcracker/cloud/dbaas/client/management/DatabasePoolMountedSecretTest.java @@ -0,0 +1,120 @@ +package com.netcracker.cloud.dbaas.client.management; + +import com.netcracker.cloud.dbaas.client.DbaasClient; +import com.netcracker.cloud.dbaas.client.entity.test.TestDBConnection; +import com.netcracker.cloud.dbaas.client.entity.test.TestDBType; +import com.netcracker.cloud.dbaas.client.entity.test.TestDatabase; +import com.netcracker.cloud.dbaas.client.service.mountedsecret.MountedSecretSource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mockito; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; + +import static com.netcracker.cloud.dbaas.client.DbaasConst.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * Verifies the source-first behaviour added to {@link DatabasePool#createDatabase}: when a matching + * Secret is mounted, the typed database is built from it (synthetic-response) and dbaas REST is never + * called; with nothing mounted the pool falls back to REST exactly as before. + */ +class DatabasePoolMountedSecretTest { + + private static final String MS_NAME = "test-ms"; + private static final String NS = "team-a"; + + private final DbaasClient dbaasClient = Mockito.mock(DbaasClient.class); + + // DatabasePool's only constructors take the (deprecated, for-removal) DatabaseDefinitionHandler, + // which the REST-fallback path still invokes, so a stub is required here. + @SuppressWarnings({"deprecation", "removal"}) + private DatabasePool pool() { + return new DatabasePool(dbaasClient, MS_NAME, NS, + Collections.emptyList(), Mockito.mock(DatabaseDefinitionHandler.class)); + } + + private DbaasDbClassifier classifier() { + return new DbaasDbClassifier.Builder() + .withProperty(SCOPE, SERVICE) + .withProperty(NAMESPACE, NS) + .withProperty(MICROSERVICE_NAME, MS_NAME) + .build(); + } + + private void writeSecret(Path root) throws IOException { + Path d = Files.createDirectories(root.resolve("postgres")); + Files.writeString(d.resolve("metadata.json"), + "{\"classifier\":{\"microserviceName\":\"" + MS_NAME + "\",\"namespace\":\"" + NS + "\",\"scope\":\"service\"}," + + "\"type\":\"testdb\",\"name\":\"app_db\",\"namespace\":\"" + NS + "\"}"); + Files.writeString(d.resolve("connectionProperties.json"), + "{\"url\":\"jdbc:testdb://pg/app\",\"username\":\"app_user\",\"password\":\"secret\",\"name\":\"app_db\"}"); + } + + @Test + void buildsDatabaseFromMountedSecretWithoutCallingRest(@TempDir Path root) throws IOException { + writeSecret(root); + DatabasePool pool = pool(); + pool.setMountedSecretSource(new MountedSecretSource(root.toString())); + + TestDatabase db = pool.getOrCreateDatabase(TestDBType.INSTANCE, classifier(), DatabaseConfig.builder().build()); + + assertNotNull(db); + TestDBConnection conn = db.getConnectionProperties(); + assertEquals("jdbc:testdb://pg/app", conn.getUrl()); + assertEquals("app_user", conn.getUsername()); + assertEquals("secret", conn.getPassword()); + assertEquals(MS_NAME, db.getClassifier().get(MICROSERVICE_NAME)); + assertEquals("app_db", db.getName()); + + verify(dbaasClient, never()).getOrCreateDatabase(any(), any(), any(), any()); + } + + @Test + void buildsDatabaseFromMinimalMetadataUsingFallbacks(@TempDir Path root) throws IOException { + // metadata without top-level name/namespace and connectionProperties without "name": + // exercises the synthetic-response fallbacks (name from props, namespace from classifier, + // settings copied through). + Path d = Files.createDirectories(root.resolve("postgres")); + Files.writeString(d.resolve("metadata.json"), + "{\"classifier\":{\"microserviceName\":\"" + MS_NAME + "\",\"namespace\":\"" + NS + "\",\"scope\":\"service\"}," + + "\"type\":\"testdb\",\"settings\":{\"region\":\"eu\"}}"); + Files.writeString(d.resolve("connectionProperties.json"), + "{\"url\":\"jdbc:testdb://pg/app\",\"username\":\"u\",\"password\":\"p\"}"); + + DatabasePool pool = pool(); + pool.setMountedSecretSource(new MountedSecretSource(root.toString())); + + TestDatabase db = pool.getOrCreateDatabase(TestDBType.INSTANCE, classifier(), DatabaseConfig.builder().build()); + + assertNotNull(db); + assertEquals("jdbc:testdb://pg/app", db.getConnectionProperties().getUrl()); + assertEquals(NS, db.getNamespace(), "namespace falls back to the classifier namespace"); + assertNull(db.getName(), "name is absent in both metadata and connectionProperties"); + assertNotNull(db.getSettings()); + assertEquals("eu", db.getSettings().get("region")); + verify(dbaasClient, never()).getOrCreateDatabase(any(), any(), any(), any()); + } + + @Test + void fallsBackToRestWhenNothingMounted(@TempDir Path root) { + // empty mount directory → no index → miss → REST + DatabasePool pool = pool(); + pool.setMountedSecretSource(new MountedSecretSource(root.toString())); + + TestDatabase fromRest = new TestDatabase(); + fromRest.setNamespace(NS); + Mockito.when(dbaasClient.getOrCreateDatabase(any(), any(), any(), any())).thenReturn(fromRest); + + TestDatabase db = pool.getOrCreateDatabase(TestDBType.INSTANCE, classifier(), DatabaseConfig.builder().build()); + + assertSame(fromRest, db); + verify(dbaasClient).getOrCreateDatabase(any(), any(), any(), any()); + } +} diff --git a/dbaas-client/dbaas-client-java/dbaas-client-base/src/test/java/com/netcracker/cloud/dbaas/client/service/mountedsecret/ClassifierMatcherTest.java b/dbaas-client/dbaas-client-java/dbaas-client-base/src/test/java/com/netcracker/cloud/dbaas/client/service/mountedsecret/ClassifierMatcherTest.java new file mode 100644 index 000000000..0da32256c --- /dev/null +++ b/dbaas-client/dbaas-client-java/dbaas-client-base/src/test/java/com/netcracker/cloud/dbaas/client/service/mountedsecret/ClassifierMatcherTest.java @@ -0,0 +1,61 @@ +package com.netcracker.cloud.dbaas.client.service.mountedsecret; + +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ClassifierMatcherTest { + + @Test + void matchingKeyCollapsesNullTypeAndRoleToEmptySegments() { + Map c = Map.of("scope", "service", "microserviceName", "svc"); + String key = ClassifierMatcher.matchingKey(c, null, null); + assertTrue(key.endsWith("||"), "null type and null role become empty key segments"); + } + + @Test + void matchingKeyLowerCasesTypeAndTrimsRole() { + Map c = Map.of("scope", "service", "microserviceName", "svc"); + assertEquals(ClassifierMatcher.matchingKey(c, "postgresql", "admin"), + ClassifierMatcher.matchingKey(c, "POSTGRESQL", " admin ")); + } + + @Test + void canonicalIgnoresNullValuesAndEmptyNestedObjectsAndKeepsScalars() { + Map full = new LinkedHashMap<>(); + full.put("scope", "SERVICE"); // lower-cased + full.put("microserviceName", "svc"); + full.put("ignored", null); // null value -> omitted + full.put("emptyNested", new HashMap<>()); // empty nested object -> dropped + full.put("count", 3); // number kept as-is + full.put("flag", true); // boolean kept as-is + + Map equivalent = new LinkedHashMap<>(); + equivalent.put("microserviceName", "svc"); + equivalent.put("scope", "service"); + equivalent.put("count", 3); + equivalent.put("flag", true); + + assertEquals(ClassifierMatcher.canonical(equivalent), ClassifierMatcher.canonical(full), + "null values and empty nested objects must not affect the canonical key"); + } + + @Test + void canonicalKeepsNonEmptyNestedObjects() { + Map withNested = new LinkedHashMap<>(); + withNested.put("scope", "service"); + Map ck = new HashMap<>(); + ck.put("logicalDbName", "x"); + withNested.put("customKeys", ck); + + Map withoutNested = Map.of("scope", "service"); + assertNotEquals(ClassifierMatcher.canonical(withoutNested), ClassifierMatcher.canonical(withNested), + "a non-empty nested object is part of the canonical key"); + } +} diff --git a/dbaas-client/dbaas-client-java/dbaas-client-base/src/test/java/com/netcracker/cloud/dbaas/client/service/mountedsecret/MountedSecretSourceTest.java b/dbaas-client/dbaas-client-java/dbaas-client-base/src/test/java/com/netcracker/cloud/dbaas/client/service/mountedsecret/MountedSecretSourceTest.java new file mode 100644 index 000000000..8d926b1a7 --- /dev/null +++ b/dbaas-client/dbaas-client-java/dbaas-client-base/src/test/java/com/netcracker/cloud/dbaas/client/service/mountedsecret/MountedSecretSourceTest.java @@ -0,0 +1,235 @@ +package com.netcracker.cloud.dbaas.client.service.mountedsecret; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; + +import static org.junit.jupiter.api.Assertions.*; + +class MountedSecretSourceTest { + + @TempDir + Path root; + + // ── helpers ─────────────────────────────────────────────────────────────── + + private void writeSecret(String dir, String metadataJson, String connPropsJson) throws IOException { + Path d = Files.createDirectories(root.resolve(dir)); + if (metadataJson != null) { + Files.writeString(d.resolve(MountedSecretSource.METADATA_FILE), metadataJson); + } + if (connPropsJson != null) { + Files.writeString(d.resolve(MountedSecretSource.CONNECTION_PROPERTIES_FILE), connPropsJson); + } + } + + private MountedSecretSource source() { + return new MountedSecretSource(root.toString()); + } + + private Map classifier(String ns) { + Map c = new TreeMap<>(); + c.put("microserviceName", "svc"); + c.put("scope", "service"); + c.put("namespace", ns); + return c; + } + + private String metadata(String classifierJson, String type, String userRole) { + String role = userRole == null ? "" : "\"userRole\":\"" + userRole + "\","; + return "{\"classifier\":" + classifierJson + ",\"type\":\"" + type + "\"," + role + + "\"name\":\"app_db\",\"namespace\":\"team-a\"}"; + } + + private static final String SERVICE_CLASSIFIER = + "{\"microserviceName\":\"svc\",\"scope\":\"service\",\"namespace\":\"team-a\"}"; + + // ── tests ───────────────────────────────────────────────────────────────── + + @Test + void serviceScopeHitReturnsPropsAndMetadata() throws IOException { + writeSecret("s1", + metadata(SERVICE_CLASSIFIER, "postgresql", null), + "{\"host\":\"pg\",\"port\":5432,\"username\":\"u\",\"password\":\"p\"}"); + + Optional r = + source().resolve(classifier("team-a"), "postgresql", null); + + assertTrue(r.isPresent()); + assertEquals("pg", r.get().connectionProperties().get("host")); + assertEquals("postgresql", r.get().metadata().getType()); + assertEquals("app_db", r.get().metadata().getName()); + } + + @Test + void missOnDifferentClassifierFallsThrough() throws IOException { + writeSecret("s1", metadata(SERVICE_CLASSIFIER, "postgresql", null), "{\"host\":\"pg\"}"); + + assertTrue(source().resolve(classifier("other-ns"), "postgresql", null).isEmpty()); + assertTrue(source().resolve(classifier("team-a"), "mongodb", null).isEmpty()); + } + + @Test + void readsConnectionPropertiesFreshEveryCall() throws IOException { + writeSecret("s1", metadata(SERVICE_CLASSIFIER, "postgresql", null), "{\"password\":\"v1\"}"); + MountedSecretSource src = source(); + + assertEquals("v1", src.resolve(classifier("team-a"), "postgresql", null).get().connectionProperties().get("password")); + + // rotate the password on disk — the next resolve must read it fresh (no caching). + Files.writeString(root.resolve("s1").resolve(MountedSecretSource.CONNECTION_PROPERTIES_FILE), "{\"password\":\"v2\"}"); + assertEquals("v2", src.resolve(classifier("team-a"), "postgresql", null).get().connectionProperties().get("password")); + } + + @Test + void readsMetadataFreshEveryCall() throws IOException { + // name n1; classifier/type/role unchanged so the index key stays the same. + writeSecret("s1", + "{\"classifier\":" + SERVICE_CLASSIFIER + ",\"type\":\"postgresql\",\"name\":\"n1\",\"namespace\":\"team-a\"}", + "{\"host\":\"pg\"}"); + MountedSecretSource src = source(); + assertEquals("n1", src.resolve(classifier("team-a"), "postgresql", null).get().metadata().getName()); + + // change a non-key descriptor field on disk — the next resolve must reflect it (fresh metadata, not cached). + Files.writeString(root.resolve("s1").resolve(MountedSecretSource.METADATA_FILE), + "{\"classifier\":" + SERVICE_CLASSIFIER + ",\"type\":\"postgresql\",\"name\":\"n2\",\"namespace\":\"team-a\"}"); + assertEquals("n2", src.resolve(classifier("team-a"), "postgresql", null).get().metadata().getName()); + } + + @Test + void metadataChangedInPlaceIsEvictedAndNoLongerServesOldClassifier() throws IOException { + writeSecret("s1", metadata(SERVICE_CLASSIFIER, "postgresql", null), "{\"host\":\"pg\"}"); + MountedSecretSource src = source(); + assertTrue(src.resolve(classifier("team-a"), "postgresql", null).isPresent()); + + // The descriptor's classifier changes in place (different microserviceName -> different key). + String movedClassifier = "{\"microserviceName\":\"other\",\"scope\":\"service\",\"namespace\":\"team-a\"}"; + Files.writeString(root.resolve("s1").resolve(MountedSecretSource.METADATA_FILE), + metadata(movedClassifier, "postgresql", null)); + + assertTrue(src.resolve(classifier("team-a"), "postgresql", null).isEmpty(), + "a descriptor that changed classifier in place must be evicted, not served under the old key"); + } + + @Test + void missingMetadataSkipsDirectory() throws IOException { + writeSecret("s1", null, "{\"host\":\"pg\"}"); + assertTrue(source().resolve(classifier("team-a"), "postgresql", null).isEmpty()); + } + + @Test + void corruptMetadataSkipsDirectory() throws IOException { + writeSecret("s1", "{not-json", "{\"host\":\"pg\"}"); + assertTrue(source().resolve(classifier("team-a"), "postgresql", null).isEmpty()); + } + + @Test + void incompleteMetadataMissingTypeSkipsDirectory() throws IOException { + // valid JSON but no `type` → not indexable + writeSecret("s1", "{\"classifier\":" + SERVICE_CLASSIFIER + "}", "{\"host\":\"pg\"}"); + assertTrue(source().resolve(classifier("team-a"), "postgresql", null).isEmpty()); + } + + @Test + void missingConnectionPropertiesIsAMiss() throws IOException { + writeSecret("s1", metadata(SERVICE_CLASSIFIER, "postgresql", null), null); + assertTrue(source().resolve(classifier("team-a"), "postgresql", null).isEmpty()); + } + + @Test + void corruptConnectionPropertiesIsAMiss() throws IOException { + writeSecret("s1", metadata(SERVICE_CLASSIFIER, "postgresql", null), "{not-json"); + assertTrue(source().resolve(classifier("team-a"), "postgresql", null).isEmpty()); + } + + @Test + void duplicateKeyResolvesDeterministicallyByLowestDirName() throws IOException { + // two directories canonicalize to the same (classifier, type, role) key + String md = metadata(SERVICE_CLASSIFIER, "postgresql", null); + // Written out of order on purpose; the lowest directory name ("a") must win regardless. + writeSecret("b", md, "{\"host\":\"from-b\"}"); + writeSecret("a", md, "{\"host\":\"from-a\"}"); + + assertEquals("from-a", + source().resolve(classifier("team-a"), "postgresql", null).get().connectionProperties().get("host")); + // Stable across separate index builds (i.e. restarts/re-scans). + assertEquals("from-a", + source().resolve(classifier("team-a"), "postgresql", null).get().connectionProperties().get("host")); + } + + @Test + void nonDirectoryEntriesAreIgnored() throws IOException { + Files.writeString(root.resolve("stray-file.txt"), "not a secret"); + writeSecret("s1", metadata(SERVICE_CLASSIFIER, "postgresql", null), "{\"host\":\"pg\"}"); + assertTrue(source().resolve(classifier("team-a"), "postgresql", null).isPresent()); + } + + @Test + void rescanOnMissPicksUpNewlyAddedSecret() throws IOException { + // Duration.ZERO disables the throttle so the miss triggers an immediate re-scan. + MountedSecretSource src = new MountedSecretSource(root.toString(), Duration.ZERO); + assertTrue(src.resolve(classifier("team-a"), "postgresql", null).isEmpty(), "index starts empty"); + + writeSecret("s1", metadata(SERVICE_CLASSIFIER, "postgresql", null), "{\"host\":\"pg\"}"); + assertTrue(src.resolve(classifier("team-a"), "postgresql", null).isPresent(), + "a re-scan on miss must pick up a secret added after construction"); + } + + @Test + void roleMatchingIsExactAndEmptyMatchesUnset() throws IOException { + // descriptor with userRole=admin + writeSecret("admin", + metadata(SERVICE_CLASSIFIER, "postgresql", "admin"), + "{\"role\":\"admin\"}"); + MountedSecretSource src = source(); + assertTrue(src.resolve(classifier("team-a"), "postgresql", "admin").isPresent()); + assertTrue(src.resolve(classifier("team-a"), "postgresql", "ro").isEmpty()); + assertTrue(src.resolve(classifier("team-a"), "postgresql", null).isEmpty()); + assertTrue(src.resolve(classifier("team-a"), "postgresql", " admin ").isPresent(), "role is trimmed"); + } + + @Test + void emptyRoleMatchesDescriptorWithUnsetUserRole() throws IOException { + writeSecret("s1", metadata(SERVICE_CLASSIFIER, "postgresql", null), "{\"host\":\"pg\"}"); + MountedSecretSource src = source(); + assertTrue(src.resolve(classifier("team-a"), "postgresql", null).isPresent()); + assertTrue(src.resolve(classifier("team-a"), "postgresql", "").isPresent()); + assertTrue(src.resolve(classifier("team-a"), "postgresql", "admin").isEmpty()); + } + + @Test + void matchIsKeyOrderScopeCaseAndEmptyFieldInsensitive() throws IOException { + // descriptor: scope lowercase "service", nested customKeys, no tenantId + writeSecret("s1", + metadata("{\"namespace\":\"team-a\",\"scope\":\"service\",\"microserviceName\":\"svc\",\"customKeys\":{\"logicalDbName\":\"x\"}}", "postgresql", null), + "{\"host\":\"pg\"}"); + MountedSecretSource src = source(); + + // request: different key order, scope upper-case, customKeys present, an explicit empty tenantId + Map req = new LinkedHashMap<>(); + req.put("scope", "SERVICE"); + Map ck = new LinkedHashMap<>(); + ck.put("logicalDbName", "x"); + req.put("customKeys", ck); + req.put("tenantId", ""); // empty top-level tenantId is omitted by canonicalization + req.put("namespace", "team-a"); + req.put("microserviceName", "svc"); + + assertTrue(src.resolve(req, "POSTGRESQL", null).isPresent(), + "canonical match must ignore key order, scope case, type case and empty namespace/tenantId"); + } + + @Test + void absentMountPathYieldsNoIndexAndAllMisses() { + MountedSecretSource src = new MountedSecretSource(root.resolve("does-not-exist").toString()); + assertTrue(src.resolve(classifier("team-a"), "postgresql", null).isEmpty()); + } +}