diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aceb77..a9df42e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -### External Service Jira (`exterla-service-jira`) +### External Service Jira (`external-service-jira`) - **New module** for checking project existance in Jira (Server) +- Caching for the client #### External Service API Module (`external-service-api`) - **New module** for standardizing external service integrations @@ -24,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Updated all external service implementations to extend `ExternalService` interface - Use of lombok.extern.slf4j.Slf4j to remove boilerplate code. +- Use **io.fabric8** in the `external-service-ocp` +- Add caching to `external-service-ocp` client factory. ### Dependencies - Updated Spring Boot version @@ -140,12 +143,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Technical Stack #### Frameworks & Libraries -- **Spring Boot**: 3.5.7 +- **Spring Boot**: 3.5.11 - **Spring Security**: OAuth2 Resource Server - **SpringDoc OpenAPI**: 2.8.13 - **OpenTelemetry**: 2.20.1 - **Lombok**: 1.18.42 - **Jackson Databind Nullable**: 0.2.7 +- **io.Fabric8**: 7.5.2 #### Build Tools - **Maven**: 3.x @@ -171,11 +175,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 This is the initial release of the DevStack API Service - a stateless, microservices-based platform for managing DevStack project lifecycles. The service provides RESTful APIs for third-party applications and CLI tools, with minimal server-side data storage and integration with external identity providers. ### Key Highlights -- ✅ Production-ready Spring Boot 3.5.7 application +- ✅ Production-ready Spring Boot 3.5.11 application - ✅ Complete API documentation with OpenAPI/Swagger - ✅ Multiple deployment options (JAR, Native Binary, Docker) - ✅ Comprehensive external service integrations -- ✅ CI/CD pipeline with automated releases - ✅ Observable with OpenTelemetry support - ✅ Secure with OAuth2 authentication diff --git a/README.md b/README.md index 7ae7011..c3e6c89 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ The system will expose RESTful APIs for third-party client applications and futu Before using the Makefile, ensure you have the following installed: ### Required -- **Java 17+** - The project uses Java 17 +- **Java 17+** - The project uses Java 21 - **Maven** - Maven wrapper (`mvnw`) is included in the project - **Make** - For running Makefile commands @@ -265,15 +265,17 @@ Port 8080 was already in use ## 📁 Project Structure ``` -devstack-api-service/ -├── Makefile # Build automation -├── pom.xml # Parent POM -├── mvnw # Maven wrapper -├── core/ # Main application module +ods-api-service/ +├── Makefile # Build automation +├── pom.xml # Parent POM +├── mvnw # Maven wrapper +├── api-xxx # Api module +├── external-service-yyy # External service module +├── core/ # Main application module │ ├── pom.xml # Core module POM │ ├── src/ # Source code │ └── target/ # Build output -└── docker/ # Docker configuration +└── docker/ # Docker configuration ├── Dockerfile # Standard container definition └── Docker.native # Native container definition ``` diff --git a/external-service-jira/src/main/java/org/opendevstack/apiservice/externalservice/jira/client/JiraApiClientFactory.java b/external-service-jira/src/main/java/org/opendevstack/apiservice/externalservice/jira/client/JiraApiClientFactory.java index 25bc815..1e62618 100644 --- a/external-service-jira/src/main/java/org/opendevstack/apiservice/externalservice/jira/client/JiraApiClientFactory.java +++ b/external-service-jira/src/main/java/org/opendevstack/apiservice/externalservice/jira/client/JiraApiClientFactory.java @@ -80,7 +80,7 @@ public String getDefaultInstanceName() throws JiraException { * @return Configured JiraApiClient * @throws JiraException if the instance is not configured */ - @Cacheable(value = "jiraApiClients", key = "#instanceName") + @Cacheable(value = "jiraApiClients", key = "#instanceName", condition = "#instanceName != null && !#instanceName.isBlank()") public JiraApiClient getClient(String instanceName) throws JiraException { if (instanceName == null || instanceName.isBlank()) { throw new JiraException( diff --git a/external-service-jira/src/test/java/org/opendevstack/apiservice/externalservice/jira/integration/JiraServiceIntegrationTest.java b/external-service-jira/src/test/java/org/opendevstack/apiservice/externalservice/jira/integration/JiraServiceIntegrationTest.java index 1616a8e..4838d0e 100644 --- a/external-service-jira/src/test/java/org/opendevstack/apiservice/externalservice/jira/integration/JiraServiceIntegrationTest.java +++ b/external-service-jira/src/test/java/org/opendevstack/apiservice/externalservice/jira/integration/JiraServiceIntegrationTest.java @@ -113,6 +113,18 @@ void testProjectExists_NonExistentProject() throws JiraException { log.info("Project '{}' exists on instance '{}': {}", testNonExistentProjectKey, testInstance, exists); } + @Test + void testProjectExists_nullInstanceName() { + // This should throw a JiraException + assertThrows(JiraException.class, () -> jiraService.projectExists(null, testProjectKey), + "Calling projectExists with null instance name should throw JiraException"); + assertThrows(JiraException.class, () -> jiraService.projectExists("", testProjectKey), + "Calling projectExists with blank instance name should throw JiraException"); + assertThrows(JiraException.class, () -> jiraService.projectExists(" ", testProjectKey), + "Calling projectExists with whitespace instance name should throw JiraException"); + + } + // ------------------------------------------------------------------------- // Default-instance tests // ------------------------------------------------------------------------- @@ -147,13 +159,4 @@ void testProjectExists_UsingDefaultInstance_NonExistentProject() throws JiraExce log.info("projectExists('{}') via default instance returned: {}", testNonExistentProjectKey, exists); } - @Test - void testProjectExists_NullInstance_SameAsDefaultInstance() throws JiraException { - // Passing null explicitly must produce the same result as the no-arg overload - boolean viaNull = jiraService.projectExists(null, testProjectKey); - boolean viaDefault = jiraService.projectExists(testProjectKey); - - assertEquals(viaDefault, viaNull, - "Passing null instanceName should behave identically to the default-instance overload"); - } } diff --git a/external-service-ocp/pom.xml b/external-service-ocp/pom.xml index 27c9f8f..b1e3e09 100644 --- a/external-service-ocp/pom.xml +++ b/external-service-ocp/pom.xml @@ -27,22 +27,17 @@ spring-boot-starter - - - org.springframework.boot - spring-boot-starter-web - - org.springframework.boot spring-boot-starter-actuator - + - com.fasterxml.jackson.core - jackson-databind + io.fabric8 + openshift-client + 7.5.2 @@ -58,13 +53,6 @@ spring-boot-starter-test test - - - - org.wiremock.integrations - wiremock-spring-boot - 3.10.0 - diff --git a/external-service-ocp/src/main/java/org/opendevstack/apiservice/externalservice/ocp/client/OpenshiftApiClient.java b/external-service-ocp/src/main/java/org/opendevstack/apiservice/externalservice/ocp/client/OpenshiftApiClient.java index c7c2e30..5ce0d1c 100644 --- a/external-service-ocp/src/main/java/org/opendevstack/apiservice/externalservice/ocp/client/OpenshiftApiClient.java +++ b/external-service-ocp/src/main/java/org/opendevstack/apiservice/externalservice/ocp/client/OpenshiftApiClient.java @@ -2,19 +2,22 @@ import org.opendevstack.apiservice.externalservice.ocp.config.OpenshiftServiceConfiguration.OpenshiftInstanceConfig; import org.opendevstack.apiservice.externalservice.ocp.exception.OpenshiftException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import io.fabric8.kubernetes.api.model.Secret; +import io.fabric8.kubernetes.api.model.authorization.v1.SelfSubjectAccessReview; +import io.fabric8.kubernetes.api.model.authorization.v1.SelfSubjectAccessReviewBuilder; +import io.fabric8.kubernetes.api.model.authorization.v1.ResourceAttributes; +import io.fabric8.kubernetes.api.model.authorization.v1.ResourceAttributesBuilder; +import io.fabric8.kubernetes.client.KubernetesClientException; +import io.fabric8.openshift.api.model.Project; +import io.fabric8.openshift.client.OpenShiftClient; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.*; -import org.springframework.web.client.RestClientException; -import org.springframework.web.client.RestTemplate; import java.util.Base64; import java.util.HashMap; import java.util.Map; /** - * Client for interacting with OpenShift API. + * Client for interacting with OpenShift API using Fabric8 OpenShift Client. * Provides methods to retrieve secrets and other resources from an OpenShift cluster. */ @Slf4j @@ -22,21 +25,19 @@ public class OpenshiftApiClient { private final String instanceName; private final OpenshiftInstanceConfig config; - private final RestTemplate restTemplate; - private final ObjectMapper objectMapper; + private final OpenShiftClient openShiftClient; /** * Constructor for OpenshiftApiClient * * @param instanceName Name of the OpenShift instance * @param config Configuration for this instance - * @param restTemplate RestTemplate configured with appropriate timeouts and SSL settings + * @param openShiftClient Fabric8 OpenShift client configured for this instance */ - public OpenshiftApiClient(String instanceName, OpenshiftInstanceConfig config, RestTemplate restTemplate) { + public OpenshiftApiClient(String instanceName, OpenshiftInstanceConfig config, OpenShiftClient openShiftClient) { this.instanceName = instanceName; this.config = config; - this.restTemplate = restTemplate; - this.objectMapper = new ObjectMapper(); + this.openShiftClient = openShiftClient; } /** @@ -62,36 +63,25 @@ public Map getSecret(String secretName, String namespace) throws log.debug("Retrieving secret '{}' from namespace '{}' in OpenShift instance '{}'", secretName, namespace, instanceName); - String url = String.format("%s/api/v1/namespaces/%s/secrets/%s", - config.getApiUrl(), namespace, secretName); - try { - HttpHeaders headers = createHeaders(); - HttpEntity entity = new HttpEntity<>(headers); - - ResponseEntity response = restTemplate.exchange( - url, - HttpMethod.GET, - entity, - String.class - ); + Secret secret = openShiftClient.secrets() + .inNamespace(namespace) + .withName(secretName) + .get(); - if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) { - return parseSecretData(response.getBody()); - } else { + if (secret == null) { throw new OpenshiftException( - String.format("Failed to retrieve secret '%s' from namespace '%s'. Status: %s", - secretName, namespace, response.getStatusCode()) + String.format("Failed to retrieve secret '%s' from namespace '%s'. Secret not found.", + secretName, namespace) ); } - } catch (RestClientException e) { + return decodeSecretData(secret); + + } catch (KubernetesClientException e) { log.error("Error retrieving secret '{}' from OpenShift instance '{}'", secretName, instanceName, e); throw new OpenshiftException( - String.format("Failed to retrieve secret '%s' from OpenShift instance '%s'", - secretName, instanceName), - e - ); + String.format("Failed to retrieve secret '%s' from OpenShift instance '%s'", secretName, instanceName), e); } } @@ -135,7 +125,7 @@ public String getSecretValue(String secretName, String key, String namespace) th * @param secretName Name of the secret * @return true if the secret exists, false otherwise */ - public boolean secretExists(String secretName) { + public boolean secretExists(String secretName) throws OpenshiftException { return secretExists(secretName, config.getNamespace()); } @@ -146,57 +136,87 @@ public boolean secretExists(String secretName) { * @param namespace Namespace where the secret might be located * @return true if the secret exists, false otherwise */ - public boolean secretExists(String secretName, String namespace) { + public boolean secretExists(String secretName, String namespace) throws OpenshiftException { try { - getSecret(secretName, namespace); - return true; - } catch (OpenshiftException e) { - log.debug("Secret '{}' does not exist in namespace '{}'", secretName, namespace); - return false; + Secret secret = openShiftClient.secrets() + .inNamespace(namespace) + .withName(secretName) + .get(); + boolean exists = secret != null; + if (!exists) { + log.debug("Secret '{}' does not exist in namespace '{}'", secretName, namespace); + } + return exists; + } catch (KubernetesClientException e) { + if (e.getCode() == 403) { + log.debug("Secret '{}' does not exist in namespace '{}' or you don't have access to it", secretName, namespace); + return false; + } + log.error("Error checking if secret '{}' exists in namespace '{}'", + secretName, namespace, namespace, e); + throw new OpenshiftException( + String.format("Failed to check if secret '%s' exists in OpenShift namespace '%s'", + secretName, namespace), e); } } /** - * Create HTTP headers with authentication token + * Check if a project exists in the OpenShift cluster * - * @return HttpHeaders with authorization and content type + * @param projectName Name of the project + * @return true if the project exists, false if not found + * @throws OpenshiftException if any other error occurs */ - private HttpHeaders createHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.setBearerAuth(config.getToken()); - headers.setContentType(MediaType.APPLICATION_JSON); - return headers; + public boolean projectExists(String projectName) throws OpenshiftException { + try { + log.debug("Checking if project '{}' exists in OpenShift instance '{}'", projectName, instanceName); + + Project project = openShiftClient.projects() + .withName(projectName) + .get(); + + if (project != null) { + log.debug("Project '{}' exists in instance '{}'", projectName, instanceName); + return true; + } else { + log.debug("Project '{}' does not exist in instance '{}'", projectName, instanceName); + return false; + } + + } catch (KubernetesClientException e) { + if (e.getCode() == 403) { + log.debug("Project '{}' does not exist in instance '{}' or you don't have access to it", projectName, instanceName); + return false; + } + log.error("Error checking if project '{}' exists in instance '{}': {}", + projectName, instanceName, e.getMessage(), e); + throw new OpenshiftException( + String.format("Failed to check if project '%s' exists in OpenShift instance '%s'", + projectName, instanceName), + e + ); + } } /** - * Parse secret data from the API response and decode base64 values + * Decode secret data from a Fabric8 Secret object, decoding base64 values * - * @param jsonResponse JSON response from OpenShift API + * @param secret The Fabric8 Secret object * @return Map with decoded secret values - * @throws OpenshiftException if parsing fails */ - private Map parseSecretData(String jsonResponse) throws OpenshiftException { - try { - JsonNode root = objectMapper.readTree(jsonResponse); - JsonNode dataNode = root.get("data"); - - Map secretData = new HashMap<>(); - - if (dataNode != null && dataNode.isObject()) { - dataNode.fieldNames().forEachRemaining(key -> { - String base64Value = dataNode.get(key).asText(); - String decodedValue = decodeBase64(base64Value); - secretData.put(key, decodedValue); - }); - } - - log.debug("Successfully parsed secret data with {} keys", secretData.size()); - return secretData; - - } catch (Exception e) { - log.error("Error parsing secret data", e); - throw new OpenshiftException("Failed to parse secret data from OpenShift response", e); + private Map decodeSecretData(Secret secret) { + Map secretData = new HashMap<>(); + Map data = secret.getData(); + + if (data != null) { + data.forEach((key, base64Value) -> { + String decodedValue = decodeBase64(base64Value); + secretData.put(key, decodedValue); + }); } + + log.debug("Successfully parsed secret data with {} keys", secretData.size()); + return secretData; } /** @@ -232,5 +252,77 @@ public String getInstanceName() { public String getDefaultNamespace() { return config.getNamespace(); } + + /** + * Check if the current service account has permission to get secrets in the given namespace. + * Uses a SelfSubjectAccessReview to verify RBAC permissions. + * + * @param namespace Namespace to check permissions in + * @return true if the account can get secrets, false otherwise + */ + public boolean canGetSecrets(String namespace) { + return canGetSecrets(namespace, null); + } + + /** + * Check if the current service account has permission to get a specific secret (or secrets in general) + * in the given namespace. Uses a SelfSubjectAccessReview to verify RBAC permissions. + * + * @param namespace Namespace to check permissions in + * @param secretName Optional specific secret name; if null, checks general access to secrets + * @return true if the account can get secrets, false otherwise + */ + public boolean canGetSecrets(String namespace, String secretName) { + try { + log.debug("Checking 'get' permission on secrets in namespace '{}' for instance '{}'", + namespace, instanceName); + + ResourceAttributesBuilder raBuilder = new ResourceAttributesBuilder() + .withNamespace(namespace) + .withVerb("get") + .withResource("secrets"); + + if (secretName != null && !secretName.isEmpty()) { + raBuilder.withName(secretName); + } + + SelfSubjectAccessReview review = new SelfSubjectAccessReviewBuilder() + .withNewSpec() + .withResourceAttributes(raBuilder.build()) + .endSpec() + .build(); + + SelfSubjectAccessReview result = openShiftClient.authorization().v1() + .selfSubjectAccessReview() + .create(review); + + boolean allowed = result.getStatus() != null && Boolean.TRUE.equals(result.getStatus().getAllowed()); + + if (!allowed) { + String reason = result.getStatus() != null ? result.getStatus().getReason() : "unknown"; + log.warn("Permission denied: cannot 'get' secrets in namespace '{}' on instance '{}'. Reason: {}", + namespace, instanceName, reason); + } else { + log.debug("Permission granted: can 'get' secrets in namespace '{}' on instance '{}'", + namespace, instanceName); + } + + return allowed; + + } catch (KubernetesClientException e) { + log.error("Error checking secret access permissions in namespace '{}' on instance '{}'", + namespace, instanceName, e); + return false; + } + } + + /** + * Close the underlying OpenShift client + */ + public void close() { + if (openShiftClient != null) { + openShiftClient.close(); + } + } } diff --git a/external-service-ocp/src/main/java/org/opendevstack/apiservice/externalservice/ocp/client/OpenshiftApiClientFactory.java b/external-service-ocp/src/main/java/org/opendevstack/apiservice/externalservice/ocp/client/OpenshiftApiClientFactory.java index d384d48..c9e2a97 100644 --- a/external-service-ocp/src/main/java/org/opendevstack/apiservice/externalservice/ocp/client/OpenshiftApiClientFactory.java +++ b/external-service-ocp/src/main/java/org/opendevstack/apiservice/externalservice/ocp/client/OpenshiftApiClientFactory.java @@ -3,23 +3,19 @@ import org.opendevstack.apiservice.externalservice.ocp.config.OpenshiftServiceConfiguration; import org.opendevstack.apiservice.externalservice.ocp.config.OpenshiftServiceConfiguration.OpenshiftInstanceConfig; import org.opendevstack.apiservice.externalservice.ocp.exception.OpenshiftException; +import io.fabric8.kubernetes.client.ConfigBuilder; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; +import io.fabric8.openshift.client.OpenShiftClient; import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.web.client.RestTemplateBuilder; -import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; -import javax.net.ssl.*; -import java.security.KeyManagementException; -import java.security.NoSuchAlgorithmException; -import java.security.cert.X509Certificate; -import java.time.Duration; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; +import java.util.Set; /** * Factory for creating OpenshiftApiClient instances. - * Uses the factory pattern to provide configured clients for different OpenShift instances. + * Uses the factory pattern to provide configured Fabric8 OpenShift clients for different instances. * Clients are cached and reused for efficiency. */ @Component @@ -27,40 +23,35 @@ public class OpenshiftApiClientFactory { private final OpenshiftServiceConfiguration configuration; - private final Map clientCache; - private final RestTemplateBuilder restTemplateBuilder; /** * Constructor with dependency injection * * @param configuration OpenShift service configuration - * @param restTemplateBuilder RestTemplate builder for creating HTTP clients */ - public OpenshiftApiClientFactory(OpenshiftServiceConfiguration configuration, - RestTemplateBuilder restTemplateBuilder) { + public OpenshiftApiClientFactory(OpenshiftServiceConfiguration configuration) { this.configuration = configuration; - this.restTemplateBuilder = restTemplateBuilder; - this.clientCache = new ConcurrentHashMap<>(); log.info("OpenshiftApiClientFactory initialized with {} instance(s)", configuration.getInstances().size()); } /** - * Get an OpenshiftApiClient for a specific instance + * Get an OpenshiftApiClient for a specific instance. + * If {@code instanceName} is {@code null} or blank, an exception is thrown. * * @param instanceName Name of the OpenShift instance * @return Configured OpenshiftApiClient * @throws OpenshiftException if the instance is not configured */ + @Cacheable(value = "openshiftApiClients", key = "#instanceName", condition = "#instanceName != null && !#instanceName.isBlank()") public OpenshiftApiClient getClient(String instanceName) throws OpenshiftException { - // Check cache first - if (clientCache.containsKey(instanceName)) { - log.debug("Returning cached client for instance '{}'", instanceName); - return clientCache.get(instanceName); + if (instanceName == null || instanceName.isBlank()) { + throw new OpenshiftException( + String.format("Provide instance name. Available instances: %s", + configuration.getInstances().keySet())); } - - // Create new client + OpenshiftInstanceConfig instanceConfig = configuration.getInstances().get(instanceName); if (instanceConfig == null) { @@ -72,21 +63,17 @@ public OpenshiftApiClient getClient(String instanceName) throws OpenshiftExcepti log.info("Creating new OpenshiftApiClient for instance '{}'", instanceName); - RestTemplate restTemplate = createRestTemplate(instanceConfig); - OpenshiftApiClient client = new OpenshiftApiClient(instanceName, instanceConfig, restTemplate); - - // Cache the client - clientCache.put(instanceName, client); - - return client; + OpenShiftClient openShiftClient = createOpenShiftClient(instanceConfig); + return new OpenshiftApiClient(instanceName, instanceConfig, openShiftClient); } /** - * Get the default client (first configured instance) + * Get the default client (first configured instance). * * @return OpenshiftApiClient for the first configured instance * @throws OpenshiftException if no instances are configured */ + @Cacheable(value = "openshiftApiClients", key = "'default'") public OpenshiftApiClient getDefaultClient() throws OpenshiftException { if (configuration.getInstances().isEmpty()) { throw new OpenshiftException("No OpenShift instances configured"); @@ -94,8 +81,10 @@ public OpenshiftApiClient getDefaultClient() throws OpenshiftException { String firstInstanceName = configuration.getInstances().keySet().iterator().next(); log.debug("Using default instance: '{}'", firstInstanceName); - - return getClient(firstInstanceName); + + OpenshiftInstanceConfig instanceConfig = configuration.getInstances().get(firstInstanceName); + OpenShiftClient openShiftClient = createOpenShiftClient(instanceConfig); + return new OpenshiftApiClient(firstInstanceName, instanceConfig, openShiftClient); } /** @@ -103,7 +92,7 @@ public OpenshiftApiClient getDefaultClient() throws OpenshiftException { * * @return Set of configured instance names */ - public java.util.Set getAvailableInstances() { + public Set getAvailableInstances() { return configuration.getInstances().keySet(); } @@ -118,73 +107,38 @@ public boolean hasInstance(String instanceName) { } /** - * Clear the client cache (useful for testing or when configuration changes) + * Clear the client cache (useful for testing or when configuration changes). */ + @CacheEvict(value = "openshiftApiClients", allEntries = true) public void clearCache() { log.info("Clearing OpenshiftApiClient cache"); - clientCache.clear(); } /** - * Create a configured RestTemplate for an OpenShift instance + * Create a configured Fabric8 OpenShiftClient for an OpenShift instance * * @param config Configuration for the instance - * @return Configured RestTemplate + * @return Configured OpenShiftClient */ - private RestTemplate createRestTemplate(OpenshiftInstanceConfig config) { - RestTemplate restTemplate = restTemplateBuilder.build(); + private OpenShiftClient createOpenShiftClient(OpenshiftInstanceConfig config) { + ConfigBuilder configBuilder = new ConfigBuilder() + .withMasterUrl(config.getApiUrl()) + .withOauthToken(config.getToken()) + .withNamespace(config.getNamespace()) + .withConnectionTimeout(config.getConnectionTimeout()) + .withRequestTimeout(config.getReadTimeout()); - // Set timeouts using SimpleClientHttpRequestFactory - SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); - requestFactory.setConnectTimeout(config.getConnectionTimeout()); - requestFactory.setReadTimeout(config.getReadTimeout()); - restTemplate.setRequestFactory(requestFactory); - - // Configure SSL if trustAllCertificates is enabled if (config.isTrustAllCertificates()) { log.warn("Trust all certificates is enabled for OpenShift connection. " + "This should only be used in development environments!"); - configureTrustAllCertificates(restTemplate); + configBuilder.withTrustCerts(true) + .withDisableHostnameVerification(true); } - return restTemplate; - } - - /** - * Configure RestTemplate to trust all SSL certificates - * WARNING: This should only be used in development environments - * - * @param restTemplate RestTemplate to configure - */ - @SuppressWarnings({"java:S4830", "java:S1186"}) // Intentionally disabling SSL validation for development - private void configureTrustAllCertificates(RestTemplate restTemplate) { - try { - TrustManager[] trustAllCerts = new TrustManager[]{ - new X509TrustManager() { - public X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0]; - } - // Intentionally empty - trusting all certificates for development environments - public void checkClientTrusted(X509Certificate[] certs, String authType) { - // No validation performed - development only - } - // Intentionally empty - trusting all certificates for development environments - public void checkServerTrusted(X509Certificate[] certs, String authType) { - // No validation performed - development only - } - } - }; - - SSLContext sslContext = SSLContext.getInstance("TLS"); - sslContext.init(null, trustAllCerts, new java.security.SecureRandom()); - - HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.getSocketFactory()); - // Intentionally disabling hostname verification for development environments - HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true); - - } catch (NoSuchAlgorithmException | KeyManagementException e) { - log.error("Failed to configure SSL trust all certificates", e); - } + return new KubernetesClientBuilder() + .withConfig(configBuilder.build()) + .build() + .adapt(OpenShiftClient.class); } } diff --git a/external-service-ocp/src/main/java/org/opendevstack/apiservice/externalservice/ocp/service/OpenshiftService.java b/external-service-ocp/src/main/java/org/opendevstack/apiservice/externalservice/ocp/service/OpenshiftService.java index a49f5a1..16d943b 100644 --- a/external-service-ocp/src/main/java/org/opendevstack/apiservice/externalservice/ocp/service/OpenshiftService.java +++ b/external-service-ocp/src/main/java/org/opendevstack/apiservice/externalservice/ocp/service/OpenshiftService.java @@ -75,6 +75,16 @@ public interface OpenshiftService extends ExternalService { */ boolean secretExists(String instanceName, String secretName, String namespace); + /** + * Check if a project exists in a specific OpenShift instance + * + * @param instanceName Name of the OpenShift instance + * @param projectName Name of the project + * @return true if the project exists (HTTP 200), false if not found (HTTP 404) + * @throws OpenshiftException if any other error occurs + */ + boolean projectExists(String instanceName, String projectName) throws OpenshiftException; + /** * Get all available OpenShift instance names * diff --git a/external-service-ocp/src/main/java/org/opendevstack/apiservice/externalservice/ocp/service/impl/OpenshiftServiceImpl.java b/external-service-ocp/src/main/java/org/opendevstack/apiservice/externalservice/ocp/service/impl/OpenshiftServiceImpl.java index 2e0604e..a22ac19 100644 --- a/external-service-ocp/src/main/java/org/opendevstack/apiservice/externalservice/ocp/service/impl/OpenshiftServiceImpl.java +++ b/external-service-ocp/src/main/java/org/opendevstack/apiservice/externalservice/ocp/service/impl/OpenshiftServiceImpl.java @@ -77,6 +77,7 @@ public boolean secretExists(String instanceName, String secretName) { @Override public boolean secretExists(String instanceName, String secretName, String namespace) { try { + log.debug("Checking if secret '{}' exists in namespace '{}' in instance '{}'", secretName, namespace, instanceName); OpenshiftApiClient client = clientFactory.getClient(instanceName); @@ -87,6 +88,13 @@ public boolean secretExists(String instanceName, String secretName, String names } } + @Override + public boolean projectExists(String instanceName, String projectName) throws OpenshiftException { + log.debug("Checking if project '{}' exists in instance '{}'", projectName, instanceName); + OpenshiftApiClient client = clientFactory.getClient(instanceName); + return client.projectExists(projectName); + } + @Override public Set getAvailableInstances() { return clientFactory.getAvailableInstances(); diff --git a/external-service-ocp/src/test/java/org/opendevstack/apiservice/externalservice/ocp/integration/OpenshiftApiClientFactoryCacheIntegrationTest.java b/external-service-ocp/src/test/java/org/opendevstack/apiservice/externalservice/ocp/integration/OpenshiftApiClientFactoryCacheIntegrationTest.java new file mode 100644 index 0000000..ab8620d --- /dev/null +++ b/external-service-ocp/src/test/java/org/opendevstack/apiservice/externalservice/ocp/integration/OpenshiftApiClientFactoryCacheIntegrationTest.java @@ -0,0 +1,119 @@ +package org.opendevstack.apiservice.externalservice.ocp.integration; + +import org.opendevstack.apiservice.externalservice.ocp.client.OpenshiftApiClient; +import org.opendevstack.apiservice.externalservice.ocp.client.OpenshiftApiClientFactory; +import org.opendevstack.apiservice.externalservice.ocp.exception.OpenshiftException; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration tests for the {@link OpenshiftApiClientFactory} client cache. + * + *

The {@code @Cacheable} annotation on {@link OpenshiftApiClientFactory#getClient(String)} is only + * activated by the Spring AOP proxy, so caching behaviour must be verified with a real + * {@link org.springframework.boot.test.context.SpringBootTest} context rather than a plain + * Mockito unit test. + * + *

Tests define two lightweight, fake OpenShift instances via {@link TestPropertySource}. + * No real OpenShift connectivity is required – the clients are created but never used to make + * API calls. + */ +@SpringBootTest(classes = OpenshiftIntegrationTestConfig.class) +@TestPropertySource(properties = { + "externalservices.openshift.instances.dev.api-url=https://api.dev.ocp.example.com:6443", + "externalservices.openshift.instances.dev.token=fake-dev-token", + "externalservices.openshift.instances.dev.namespace=dev-ns", + "externalservices.openshift.instances.dev.trust-all-certificates=true", + "externalservices.openshift.instances.staging.api-url=https://api.staging.ocp.example.com:6443", + "externalservices.openshift.instances.staging.token=fake-staging-token", + "externalservices.openshift.instances.staging.namespace=staging-ns", + "externalservices.openshift.instances.staging.trust-all-certificates=true" +}) +class OpenshiftApiClientFactoryCacheIntegrationTest { + + @Autowired + private OpenshiftApiClientFactory factory; + + // ------------------------------------------------------------------------- + // Same instance name → same cached instance + // ------------------------------------------------------------------------- + + @Test + void getClient_sameInstanceName_returnsCachedInstance() throws OpenshiftException { + OpenshiftApiClient first = factory.getClient("dev"); + OpenshiftApiClient second = factory.getClient("dev"); + + assertSame(first, second, + "Repeated calls with the same instance name must return the cached client"); + } + + @Test + void getClient_sameInstanceName_multipleCallsAlwaysReturnSameInstance() throws OpenshiftException { + OpenshiftApiClient reference = factory.getClient("staging"); + + for (int i = 0; i < 5; i++) { + assertSame(reference, factory.getClient("staging"), + "Call #" + (i + 1) + " should return the same cached client for 'staging'"); + } + } + + // ------------------------------------------------------------------------- + // Different instance names → different instances + // ------------------------------------------------------------------------- + + @Test + void getClient_differentInstanceNames_returnDifferentInstances() throws OpenshiftException { + OpenshiftApiClient dev = factory.getClient("dev"); + OpenshiftApiClient staging = factory.getClient("staging"); + + assertNotSame(dev, staging, + "Different instance names must produce distinct client objects"); + assertEquals("dev", dev.getInstanceName()); + assertEquals("staging", staging.getInstanceName()); + } + + // ------------------------------------------------------------------------- + // Default client is cached + // ------------------------------------------------------------------------- + + @Test + void getDefaultClient_returnsCachedInstance() throws OpenshiftException { + OpenshiftApiClient first = factory.getDefaultClient(); + OpenshiftApiClient second = factory.getDefaultClient(); + + assertSame(first, second, + "Repeated calls to getDefaultClient must return the cached client"); + } + + // ------------------------------------------------------------------------- + // clearCache evicts all entries + // ------------------------------------------------------------------------- + + @Test + void clearCache_evictsAllCachedClients() throws OpenshiftException { + OpenshiftApiClient before = factory.getClient("dev"); + + factory.clearCache(); + + OpenshiftApiClient after = factory.getClient("dev"); + assertNotSame(before, after, + "After clearing cache, a new client instance should be created"); + } + + // ------------------------------------------------------------------------- + // Get client with null/blank instance name should throw exception + // ------------------------------------------------------------------------- + @Test + void getClient_nullOrBlankInstanceName_throwsExceptionAndDoesNotCache() { + assertThrows(OpenshiftException.class, () -> factory.getClient(null), + "Calling getClient with null instance name should throw OpenshiftException"); + assertThrows(OpenshiftException.class, () -> factory.getClient(""), + "Calling getClient with blank instance name should throw OpenshiftException"); + assertThrows(OpenshiftException.class, () -> factory.getClient(" "), + "Calling getClient with blank instance name should throw OpenshiftException"); + } +} diff --git a/external-service-ocp/src/test/java/org/opendevstack/apiservice/externalservice/ocp/integration/OpenshiftIntegrationTest.java b/external-service-ocp/src/test/java/org/opendevstack/apiservice/externalservice/ocp/integration/OpenshiftIntegrationTest.java index d9876a7..b372068 100644 --- a/external-service-ocp/src/test/java/org/opendevstack/apiservice/externalservice/ocp/integration/OpenshiftIntegrationTest.java +++ b/external-service-ocp/src/test/java/org/opendevstack/apiservice/externalservice/ocp/integration/OpenshiftIntegrationTest.java @@ -3,13 +3,11 @@ import org.opendevstack.apiservice.externalservice.ocp.exception.OpenshiftException; import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; import lombok.extern.slf4j.Slf4j; -import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.context.annotation.Configuration; import org.springframework.test.context.ActiveProfiles; import java.util.Map; @@ -18,125 +16,67 @@ /** * Integration tests for OpenShift Service - * These tests require actual OpenShift cluster connectivity + * + * This test runs against a real OpenShift cluster configured in application-local.yaml. + * It requires actual OpenShift connectivity and valid credentials. * * To run these tests: - * 1. Ensure you have proper OpenShift configuration in application-local.yaml or environment variables - * 2. Make sure the OpenShift token has access to the namespace - * 3. Remove @Disabled annotation from the test you want to run - * 4. Run with: mvn test -Dtest=OpenshiftIntegrationTest + * 1. Ensure application-local.yaml has valid OpenShift configuration + * 2. Set environment variable: OPENSHIFT_INTEGRATION_TEST_ENABLED=true + * 3. Set test cluster details: + * - OPENSHIFT_TEST_CLUSTER_API_URL (e.g., "https://api.example.ocp.cloud.com:6443") + * - OPENSHIFT_TEST_CLUSTER_TOKEN (e.g., "sha256~exampletoken1234567890abcdef") + * - OPENSHIFT_TEST_DEFAULT_NAMESPACE (e.g., "example-namespace") + * - OPENSHIFT_TEST_INSTANCE (e.g., "cluster-1" or "cluster-2") + * - OPENSHIFT_TEST_SECRET_NAME (e.g., "example-secret") + * - OPENSHIFT_TEST_PROJECT_NAME (e.g., "example-project") + * + * Example: + * export OPENSHIFT_INTEGRATION_TEST_ENABLED=true + * export OPENSHIFT_TEST_CLUSTER_API_URL="https://api.example.ocp.cloud.com:6443" + * export OPENSHIFT_TEST_CLUSTER_TOKEN="sha256~exampletoken1234567890abcdef" + * export OPENSHIFT_TEST_DEFAULT_NAMESPACE=example-namespace + * export OPENSHIFT_TEST_INSTANCE=cluster-1 + * export OPENSHIFT_TEST_SECRET_NAME=example-secret + * export OPENSHIFT_TEST_PROJECT_NAME=example-project + * + * Then run: mvn test -Dtest=OpenshiftIntegrationTest */ -@SpringBootTest(classes = OpenshiftIntegrationTest.TestConfiguration.class) +@SpringBootTest(classes = OpenshiftIntegrationTestConfig.class) @ActiveProfiles("local") +@EnabledIfEnvironmentVariable(named = "OPENSHIFT_INTEGRATION_TEST_ENABLED", matches = "true") @Slf4j -@Disabled("Integration tests require actual OpenShift cluster access - enable manually when needed") class OpenshiftIntegrationTest { - @Configuration - @EnableAutoConfiguration - @ComponentScan(basePackages = "org.opendevstack.apiservice.externalservice.ocp") - static class TestConfiguration { - } @Autowired private OpenshiftService openshiftService; - /** - * Test to retrieve a specific secret from the example-project-cd namespace in cluster-a cluster - * - * Before running this test: - * 1. Ensure OPENSHIFT_CLUSTER_A_API_URL environment variable is set - * 2. Ensure OPENSHIFT_CLUSTER_A_TOKEN environment variable is set with a valid token - * 3. Verify the token has access to the example-project-cd namespace - * 4. Remove @Disabled annotation - */ - @Test - @Disabled("Remove this annotation to run the test") - void testGetTriggerSecretFromRompiCdNamespace() { - // Configuration - String instanceName = "cluster-a"; - String namespace = "example-project-cd"; - String secretName = "webhook-proxy"; - - log.info("Attempting to retrieve secret '{}' from namespace '{}' in instance '{}'", - secretName, namespace, instanceName); - - try { - // Verify instance is configured - assertTrue(openshiftService.hasInstance(instanceName), - "Instance '" + instanceName + "' is not configured"); - log.info("✓ Instance '{}' is configured", instanceName); - - // Check if secret exists - boolean exists = openshiftService.secretExists(instanceName, secretName, namespace); - assertTrue(exists, - "Secret '" + secretName + "' does not exist in namespace '" + namespace + "'"); - log.info("✓ Secret '{}' exists in namespace '{}'", secretName, namespace); - - // Get the entire secret - Map secret = openshiftService.getSecret(instanceName, secretName, namespace); - - // Assertions - assertNotNull(secret, "Secret should not be null"); - assertFalse(secret.isEmpty(), "Secret should not be empty"); - - // Log secret keys (not values for security) - log.info("✓ Successfully retrieved secret with {} keys", secret.size()); - log.info("Secret keys: {}", secret.keySet()); - - // Example: Get a specific value from the secret - if (!secret.isEmpty()) { - String firstKey = secret.keySet().iterator().next(); - String value = openshiftService.getSecretValue(instanceName, secretName, firstKey, namespace); - assertNotNull(value, "Secret value should not be null"); - log.info("✓ Successfully retrieved value for key '{}'", firstKey); - } - - } catch (OpenshiftException e) { - log.error("Failed to retrieve secret", e); - fail("Should be able to retrieve the secret: " + e.getMessage()); - } - } - - /** - * Test to get a specific value from the webhook-proxy - * Customize this test based on the keys you know exist in the secret - */ - @Test - @Disabled("Remove this annotation and customize with actual key names") - void testGetSpecificValueFromTriggerSecret() { - String instanceName = "cluster-a"; - String namespace = "example-project-cd"; - String secretName = "webhook-proxy"; - String keyName = "trigger-secret"; // Replace with actual key name - - log.info("Attempting to retrieve key '{}' from secret '{}'", keyName, secretName); - - try { - String value = openshiftService.getSecretValue(instanceName, secretName, keyName, namespace); - - assertNotNull(value, "Secret value should not be null"); - assertFalse(value.isEmpty(), "Secret value should not be empty"); - - log.info("✓ Successfully retrieved value for key '{}'", keyName); - log.info("Value length: {} characters", value.length()); - - } catch (OpenshiftException e) { - log.error("Failed to retrieve secret value", e); - fail("Should be able to retrieve the secret value: " + e.getMessage()); - } + private String testInstance; + private String testNamespace; + private String testSecretName; + private String testProjectName; + + @BeforeEach + void setUp() { + // Read test parameters from environment variables + testInstance = System.getenv().getOrDefault("OPENSHIFT_TEST_INSTANCE", "cluster-a"); + testNamespace = System.getenv().getOrDefault("OPENSHIFT_TEST_DEFAULT_NAMESPACE", "example-project-cd"); + testSecretName = System.getenv().getOrDefault("OPENSHIFT_TEST_SECRET_NAME", "webhook-proxy"); + testProjectName = System.getenv().getOrDefault("OPENSHIFT_TEST_PROJECT_NAME", "example-project"); + + log.info("Running integration tests against OpenShift instance: {}", testInstance); + log.info("Test namespace: {}", testNamespace); + log.info("Test secret: {}", testSecretName); + log.info("Test project: {}", testProjectName); } - - /** - * Helper test to list all available instances - */ + @Test - @Disabled("Remove this annotation to check configured instances") - void testListAvailableInstances() { - log.info("Listing available OpenShift instances"); - + void testGetAvailableInstances() { + // Act var instances = openshiftService.getAvailableInstances(); - + + // Assert assertNotNull(instances, "Available instances should not be null"); assertFalse(instances.isEmpty(), "Should have at least one configured instance"); @@ -144,51 +84,192 @@ void testListAvailableInstances() { instances.forEach(instance -> { log.info(" - {}", instance); }); - - assertTrue(instances.contains("cluster-a"), - "cluster-a instance should be configured"); } - - /** - * Test to verify all keys in the webhook-proxy - * Useful to discover what's inside the secret - */ + @Test - @Disabled("Remove this annotation to discover secret contents") - void testDiscoverTriggerSecretContents() { - String instanceName = "cluster-a"; - String namespace = "example-project-cd"; - String secretName = "webhook-proxy"; - - log.info("Discovering contents of secret '{}'", secretName); + void testHasInstance() { + // Act + boolean hasInstance = openshiftService.hasInstance(testInstance); + + // Assert + assertTrue(hasInstance, "Test instance '" + testInstance + "' should be configured"); + log.info("✓ Instance '{}' is configured", testInstance); + } + + @Test + void testHasInstance_NonExistent() { + // Act + boolean hasInstance = openshiftService.hasInstance("nonexistent-instance-xyz"); + + // Assert + assertFalse(hasInstance, "Non-existent instance should return false"); + log.info("✓ Non-existent instance correctly returned false"); + } + + @Test + void testHealthCheck() { + // Act + boolean isHealthy = openshiftService.isHealthy(); + + // Assert + assertTrue(isHealthy, "OpenShift service should be healthy"); + log.info("✓ OpenShift service is healthy"); + } + + // ------------------------------------------------------------------------- + // Test secret existence. + // ------------------------------------------------------------------------- + + @Test + void testSecretExists_ExistingSecret() throws OpenshiftException { + // Act + boolean exists = openshiftService.secretExists(testInstance, testSecretName, testNamespace); + + // Assert + // Note: This may be true or false depending on the test environment + // We just verify the method executes without exception + log.info("Secret '{}' exists in namespace '{}': {}", testSecretName, testNamespace, exists); + } + + @Test + void testSecretExists_NonExistentSecret() throws OpenshiftException { + // Arrange + String nonExistentSecret = "nonexistent-secret-xyz-12345"; + + // Act + boolean exists = openshiftService.secretExists(testInstance, nonExistentSecret, testNamespace); + + // Assert + assertFalse(exists, "Non-existent secret should return false"); + log.info("✓ Verified that secret '{}' does not exist", nonExistentSecret); + } + + @Test + void testSecretExists_NamespaceNotProvided() throws OpenshiftException { + // Act + boolean exists = openshiftService.secretExists(testInstance, testSecretName); + + // Assert + // Note: This may be true or false depending on the default namespace configuration + // We just verify the method executes without exception + log.info("Secret '{}' exists in default namespace: {}", testSecretName, exists); + } + + @Test + void testSecretExists_WithConsistentBehavior() throws OpenshiftException { + // Act - Check with and without namespace + boolean existsWithNamespace = openshiftService.secretExists(testInstance, testSecretName, testNamespace); + boolean existsWithoutNamespace = openshiftService.secretExists(testInstance, testSecretName); + + // Assert - Both should return consistent results (either both true or both false) + // Note: They may differ if the default namespace is different from the test namespace + log.info("Secret '{}' exists with namespace: {}", testSecretName, existsWithNamespace); + log.info("Secret '{}' exists without namespace: {}", testSecretName, existsWithoutNamespace); + } + + // ------------------------------------------------------------------------- + // Test secret retrieval. + // ------------------------------------------------------------------------- + + @Test + void testGetSecret_Success() throws OpenshiftException { + // Act + Map secret = openshiftService.getSecret(testInstance, testSecretName, testNamespace); + + // Assert + assertNotNull(secret, "Secret should not be null"); + log.info("✓ Successfully retrieved secret with {} keys", secret.size()); - try { - Map secret = openshiftService.getSecret(instanceName, secretName, namespace); - - log.info("Secret '{}' contains the following keys:", secretName); - secret.forEach((key, value) -> { - log.info(" - Key: '{}', Value length: {} characters", key, value.length()); - }); - - // Print sanitized info (first and last 2 chars only) - secret.forEach((key, value) -> { - String sanitized = sanitizeValue(value); - log.info(" - {}: {}", key, sanitized); - }); - - } catch (OpenshiftException e) { - log.error("Failed to discover secret contents", e); - fail("Should be able to retrieve the secret: " + e.getMessage()); - } + // Log secret keys (not values for security) + log.info("Secret keys: {}", secret.keySet()); + } + + @Test + void testGetSecret_NonExistentSecret() { + // Arrange + String nonExistentSecret = "nonexistent-secret-xyz-12345"; + + // Act & Assert + OpenshiftException exception = assertThrows(OpenshiftException.class, () -> + openshiftService.getSecret(testInstance, nonExistentSecret, testNamespace) + ); + + assertTrue( + exception.getMessage().contains("Failed to retrieve") || + exception.getMessage().contains("not found"), + "Exception should indicate secret not found or retrieval failed" + ); + log.info("Expected exception for non-existent secret: {}", exception.getMessage()); } - - /** - * Sanitize sensitive values for logging - */ - private String sanitizeValue(String value) { - if (value == null || value.length() < 4) { - return "****"; + + // ------------------------------------------------------------------------- + // Test secret value retrieval. + // ------------------------------------------------------------------------- + + @Test + void testGetSecretValue_Success() throws OpenshiftException { + // First get the secret to find available keys + Map secret = openshiftService.getSecret(testInstance, testSecretName, testNamespace); + + if (!secret.isEmpty()) { + // Use the first available key + String key = secret.keySet().iterator().next(); + + // Act + String value = openshiftService.getSecretValue(testInstance, testSecretName, key, testNamespace); + + // Assert + assertNotNull(value, "Secret value should not be null"); + log.info("✓ Successfully retrieved value for key '{}'", key); + } else { + log.warn("Secret is empty, skipping value retrieval test"); } - return value.substring(0, 2) + "****" + value.substring(value.length() - 2); } + + @Test + void testGetSecretValue_NonExistentKey() { + // Arrange + String nonExistentKey = "nonexistent-key-xyz"; + + // Act & Assert + OpenshiftException exception = assertThrows(OpenshiftException.class, () -> + openshiftService.getSecretValue(testInstance, testSecretName, nonExistentKey, testNamespace) + ); + + assertTrue( + exception.getMessage().contains("not found") || + exception.getMessage().contains("Failed"), + "Exception should indicate key not found" + ); + log.info("Expected exception for non-existent key: {}", exception.getMessage()); + } + + // ------------------------------------------------------------------------- + // Test project existence. + // ------------------------------------------------------------------------- + + + @Test + void testProjectExists_ExistingProject() throws OpenshiftException { + // Act + boolean exists = openshiftService.projectExists(testInstance, testProjectName); + + // Assert + assertTrue(exists, "Project '" + testProjectName + "' should exist in instance '" + testInstance + "'"); + log.info("✓ Project '{}' exists in instance '{}'", testProjectName, testInstance); + } + + @Test + void testProjectExists_NonExistentProject() throws OpenshiftException { + // Arrange + String nonExistentProject = "nonexistent-project-xyz-12345"; + + // Act + boolean exists = openshiftService.projectExists(testInstance, nonExistentProject); + + // Assert + assertFalse(exists, "Non-existent project should return false"); + log.info("✓ Verified that project '{}' does not exist", nonExistentProject); + } + } diff --git a/external-service-ocp/src/test/java/org/opendevstack/apiservice/externalservice/ocp/integration/OpenshiftIntegrationTestConfig.java b/external-service-ocp/src/test/java/org/opendevstack/apiservice/externalservice/ocp/integration/OpenshiftIntegrationTestConfig.java new file mode 100644 index 0000000..eff596e --- /dev/null +++ b/external-service-ocp/src/test/java/org/opendevstack/apiservice/externalservice/ocp/integration/OpenshiftIntegrationTestConfig.java @@ -0,0 +1,20 @@ +package org.opendevstack.apiservice.externalservice.ocp.integration; + +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.ComponentScan; + +/** + * Test configuration for OpenShift integration tests. + * This configuration enables Spring Boot auto-configuration, caching and component scanning + * for the OpenShift service module. + */ +@SpringBootConfiguration +@EnableAutoConfiguration +@EnableCaching +@ComponentScan(basePackages = "org.opendevstack.apiservice.externalservice.ocp") +public class OpenshiftIntegrationTestConfig { + // Configuration class for integration tests + // All beans will be auto-configured by Spring Boot +} diff --git a/external-service-ocp/src/test/java/org/opendevstack/apiservice/externalservice/ocp/service/OpenshiftServiceTest.java b/external-service-ocp/src/test/java/org/opendevstack/apiservice/externalservice/ocp/service/OpenshiftServiceTest.java index 8fe4edc..9f0f64e 100644 --- a/external-service-ocp/src/test/java/org/opendevstack/apiservice/externalservice/ocp/service/OpenshiftServiceTest.java +++ b/external-service-ocp/src/test/java/org/opendevstack/apiservice/externalservice/ocp/service/OpenshiftServiceTest.java @@ -84,6 +84,7 @@ void testGetSecretWithNamespace_Success() throws OpenshiftException { verify(apiClient).getSecret(secretName, namespace); } + @Test void testGetSecretValue_Success() throws OpenshiftException { // Arrange @@ -197,6 +198,63 @@ void testSecretExists_HandlesException() throws OpenshiftException { verify(apiClient, never()).secretExists(anyString()); } + @Test + void testProjectExists_ReturnsTrue() throws OpenshiftException { + // Arrange + String instanceName = "dev"; + String projectName = "existing-project"; + + when(clientFactory.getClient(instanceName)).thenReturn(apiClient); + when(apiClient.projectExists(projectName)).thenReturn(true); + + // Act + boolean result = openshiftService.projectExists(instanceName, projectName); + + // Assert + assertTrue(result); + verify(clientFactory).getClient(instanceName); + verify(apiClient).projectExists(projectName); + } + + @Test + void testProjectExists_ReturnsFalse() throws OpenshiftException { + // Arrange + String instanceName = "dev"; + String projectName = "non-existing-project"; + + when(clientFactory.getClient(instanceName)).thenReturn(apiClient); + when(apiClient.projectExists(projectName)).thenReturn(false); + + // Act + boolean result = openshiftService.projectExists(instanceName, projectName); + + // Assert + assertFalse(result); + verify(clientFactory).getClient(instanceName); + verify(apiClient).projectExists(projectName); + } + + @Test + void testProjectExists_ThrowsException() throws OpenshiftException { + // Arrange + String instanceName = "dev"; + String projectName = "test-project"; + String errorMessage = "Connection failed"; + + when(clientFactory.getClient(instanceName)).thenReturn(apiClient); + when(apiClient.projectExists(projectName)).thenThrow(new OpenshiftException(errorMessage)); + + // Act & Assert + OpenshiftException exception = assertThrows( + OpenshiftException.class, + () -> openshiftService.projectExists(instanceName, projectName) + ); + + assertEquals(errorMessage, exception.getMessage()); + verify(clientFactory).getClient(instanceName); + verify(apiClient).projectExists(projectName); + } + @Test void testGetAvailableInstances() { // Arrange diff --git a/external-service-ocp/src/test/resources/application-local.yaml b/external-service-ocp/src/test/resources/application-local.yaml index 3ed8ade..e7ce716 100644 --- a/external-service-ocp/src/test/resources/application-local.yaml +++ b/external-service-ocp/src/test/resources/application-local.yaml @@ -3,14 +3,14 @@ logging: org.opendevstack.apiservice.externalservice.ocp: DEBUG # OpenShift Configuration for Integration Tests -externalservice: +externalservices: openshift: instances: # Cluster A OpenShift instance cluster-a: - api-url: ${OPENSHIFT_CLUSTER_A_API_URL:https://api.cluster-a.ocp.example.com:6443} - token: ${OPENSHIFT_US_TEST_TOKEN:your-token-here} - namespace: ${OPENSHIFT_US_TEST_NAMESPACE:example-project-cd} + api-url: ${OPENSHIFT_TEST_CLUSTER_API_URL:https://api.cluster-a.ocp.example.com:6443} + token: ${OPENSHIFT_TEST_CLUSTER_TOKEN:your-token-here} + namespace: ${OPENSHIFT_TEST_DEFAULT_NAMESPACE:example-project-cd} connection-timeout: 30000 read-timeout: 30000 - trust-all-certificates: ${OPENSHIFT_US_TEST_TRUST_ALL:true} + trust-all-certificates: ${OPENSHIFT_TEST_TRUST_ALL:true} diff --git a/launch.json b/launch.json new file mode 100644 index 0000000..5f969b6 --- /dev/null +++ b/launch.json @@ -0,0 +1,28 @@ + +{ + "version": "0.2.0", + "configurations": [ + { + "type": "java", + "name": "Debug (Launch) - ODS API Service", + "request": "launch", + "mainClass": "org.opendevstack.apiservice.core.DevstackApiServiceApplication", + "projectName": "core", + "envFile": "${workspaceFolder}/../dev.env" + }, + { + "type": "java", + "name": "Debug Current Test", + "request": "launch", + "mainClass": "", + "envFile": "${workspaceFolder}/../dev.env" + }, + { + "type": "java", + "name": "Debug Integration tests", + "request": "launch", + "mainClass": "", + "envFile": "${workspaceFolder}/../dev.env" + } + ] +} diff --git a/settings.json b/settings.json new file mode 100644 index 0000000..a207644 --- /dev/null +++ b/settings.json @@ -0,0 +1,24 @@ +{ + "chat.promptFilesRecommendations": { + "speckit.constitution": true, + "speckit.specify": true, + "speckit.plan": true, + "speckit.tasks": true, + "speckit.implement": true + }, + "chat.tools.terminal.autoApprove": { + ".specify/scripts/bash/": true, + ".specify/scripts/powershell/": true, + "./mvnw": true, + "mvn clean": true + }, + "java.test.config": { + "name": "Integration Tests", + "envFile": "${workspaceFolder}/../dev.env", + "vmArgs": [ + "-Dspring.profiles.active=local" + ] + }, + "java.configuration.updateBuildConfiguration": "interactive", + "java.compile.nullAnalysis.mode": "automatic" +}