From fb25a21545db2166ebcfec87ab21dcb302ec1c01 Mon Sep 17 00:00:00 2001 From: Caleb Evans Date: Wed, 25 Jun 2025 16:04:46 +0000 Subject: [PATCH 1/2] feat(analysis): start the analysis endpoint for ai-interface --- pom.xml | 34 +++- .../redhat/podmortem/GreetingResource.java | 16 -- .../redhat/podmortem/ai/rest/Analysis.java | 90 +++++++++ .../podmortem/ai/service/AnalysisService.java | 172 ++++++++++++++++++ .../ai/service/ProviderRegistry.java | 58 ++++++ src/main/resources/settings.xml | 7 +- .../redhat/podmortem/GreetingResourceIT.java | 8 - .../podmortem/GreetingResourceTest.java | 15 -- 8 files changed, 357 insertions(+), 43 deletions(-) delete mode 100644 src/main/java/com/redhat/podmortem/GreetingResource.java create mode 100644 src/main/java/com/redhat/podmortem/ai/rest/Analysis.java create mode 100644 src/main/java/com/redhat/podmortem/ai/service/AnalysisService.java create mode 100644 src/main/java/com/redhat/podmortem/ai/service/ProviderRegistry.java delete mode 100644 src/test/java/com/redhat/podmortem/GreetingResourceIT.java delete mode 100644 src/test/java/com/redhat/podmortem/GreetingResourceTest.java diff --git a/pom.xml b/pom.xml index 353ef89..37c785f 100644 --- a/pom.xml +++ b/pom.xml @@ -16,15 +16,21 @@ true 3.5.3 - 1.0-f3f9123-SNAPSHOT + 1.0-35533c7-SNAPSHOT + 1.0-073da55-SNAPSHOT - github - GitHub podmortem Apache Maven Packages + github-common + Common Library https://maven.pkg.github.com/podmortem/common-lib + + github-ai-provider + AI Provider Library + https://maven.pkg.github.com/podmortem/ai-provider-lib + @@ -48,11 +54,33 @@ io.quarkus quarkus-arc + + io.quarkus + quarkus-mutiny + + + io.quarkus + quarkus-smallrye-fault-tolerance + + + io.quarkus + quarkus-cache + + + io.quarkus + quarkus-rest-client-jackson + com.redhat.podmortem common ${podmortem.common.lib.version} + + com.redhat.podmortem + provider + ${podmortem.ai.provider.lib.version} + + io.quarkus quarkus-junit5 diff --git a/src/main/java/com/redhat/podmortem/GreetingResource.java b/src/main/java/com/redhat/podmortem/GreetingResource.java deleted file mode 100644 index 711f63e..0000000 --- a/src/main/java/com/redhat/podmortem/GreetingResource.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.redhat.podmortem; - -import jakarta.ws.rs.GET; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.core.MediaType; - -@Path("/hello") -public class GreetingResource { - - @GET - @Produces(MediaType.TEXT_PLAIN) - public String hello() { - return "Hello from Quarkus REST"; - } -} diff --git a/src/main/java/com/redhat/podmortem/ai/rest/Analysis.java b/src/main/java/com/redhat/podmortem/ai/rest/Analysis.java new file mode 100644 index 0000000..e31eaf5 --- /dev/null +++ b/src/main/java/com/redhat/podmortem/ai/rest/Analysis.java @@ -0,0 +1,90 @@ +package com.redhat.podmortem.ai.rest; + +import com.redhat.podmortem.ai.service.AnalysisService; +import com.redhat.podmortem.common.model.analysis.AnalysisRequest; +import io.smallrye.mutiny.Uni; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.Map; +import org.jboss.logging.Logger; + +@Path("/api/v1/ai-analysis") +@ApplicationScoped +public class Analysis { + + private static final Logger LOG = Logger.getLogger(Analysis.class); + + @Inject AnalysisService aiAnalysisService; + + @POST + @Path("/explain") + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + public Uni explainFailure(AnalysisRequest request) { + LOG.infof( + "Received AI analysis request for analysis ID: %s", + request.getAnalysisResult().getAnalysisId()); + + return aiAnalysisService + .analyzeFailure(request.getAnalysisResult(), request.getProviderConfig()) + .map( + response -> { + LOG.infof( + "AI analysis completed for analysis ID: %s", + request.getAnalysisResult().getAnalysisId()); + return Response.ok(response).build(); + }) + .onFailure() + .recoverWithItem( + throwable -> { + LOG.errorf( + throwable, + "AI analysis failed for analysis ID: %s", + request.getAnalysisResult().getAnalysisId()); + return Response.status(500) + .entity( + Map.of( + "error", "AI analysis failed", + "message", throwable.getMessage(), + "analysisId", + request.getAnalysisResult() + .getAnalysisId())) + .build(); + }); + } + + @GET + @Path("/health") + @Produces(MediaType.APPLICATION_JSON) + public Response healthCheck() { + return Response.ok( + Map.of( + "status", "UP", + "service", "ai-interface", + "timestamp", System.currentTimeMillis())) + .build(); + } + + @GET + @Path("/providers") + @Produces(MediaType.APPLICATION_JSON) + public Uni listProviders() { + return aiAnalysisService + .getAvailableProviders() + .map(providers -> Response.ok(Map.of("providers", providers)).build()) + .onFailure() + .recoverWithItem( + throwable -> + Response.status(500) + .entity( + Map.of( + "error", + "Failed to list providers", + "message", + throwable.getMessage())) + .build()); + } +} diff --git a/src/main/java/com/redhat/podmortem/ai/service/AnalysisService.java b/src/main/java/com/redhat/podmortem/ai/service/AnalysisService.java new file mode 100644 index 0000000..832dfea --- /dev/null +++ b/src/main/java/com/redhat/podmortem/ai/service/AnalysisService.java @@ -0,0 +1,172 @@ +package com.redhat.podmortem.ai.service; + +import com.redhat.podmortem.common.model.analysis.AnalysisResult; +import com.redhat.podmortem.common.model.provider.*; +import io.smallrye.mutiny.Uni; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.stream.Collectors; +import org.eclipse.microprofile.faulttolerance.CircuitBreaker; +import org.eclipse.microprofile.faulttolerance.Fallback; +import org.eclipse.microprofile.faulttolerance.Retry; +import org.eclipse.microprofile.faulttolerance.Timeout; +import org.jboss.logging.Logger; + +@ApplicationScoped +public class AnalysisService { + + private static final Logger LOG = Logger.getLogger(AnalysisService.class); + + @Inject ProviderRegistry providerRegistry; + + @CircuitBreaker( + requestVolumeThreshold = 10, + failureRatio = 0.5, + successThreshold = 3, + delay = 5000) + @Retry(maxRetries = 3, delay = 1000) + @Timeout(value = 30, unit = ChronoUnit.SECONDS) + public Uni analyzeFailure( + AnalysisResult analysisResult, AIProviderConfig providerConfig) { + LOG.infof( + "Starting AI analysis for analysis ID: %s using provider: %s", + analysisResult.getAnalysisId(), providerConfig.getProviderId()); + + try { + // get the AI provider implementation from ai-provider-lib + AIProvider provider = providerRegistry.getProvider(providerConfig.getProviderId()); + + return provider.generateExplanation(analysisResult, providerConfig) + .map(response -> enrichResponse(response, analysisResult)) + .onFailure() + .invoke( + throwable -> + LOG.errorf( + throwable, + "AI provider call failed for provider: %s", + providerConfig.getProviderId())); + + } catch (Exception e) { + LOG.errorf(e, "Failed to get AI provider: %s", providerConfig.getProviderId()); + return Uni.createFrom().failure(e); + } + } + + @Fallback(fallbackMethod = "generateFallbackExplanation") + public Uni protectedAnalyzeFailure( + AnalysisResult analysisResult, AIProviderConfig providerConfig) { + return analyzeFailure(analysisResult, providerConfig); + } + + public Uni generateFallbackExplanation( + AnalysisResult analysisResult, AIProviderConfig providerConfig) { + LOG.warnf("Using fallback explanation for analysis ID: %s", analysisResult.getAnalysisId()); + + // basic explanation based on analysis results when AI is unavailable + String fallbackExplanation = buildBasicExplanation(analysisResult); + + AIResponse fallbackResponse = new AIResponse(); + fallbackResponse.setExplanation(fallbackExplanation); + fallbackResponse.setProviderId("fallback"); + fallbackResponse.setModelId("pattern-based"); + fallbackResponse.setGeneratedAt(Instant.now()); + fallbackResponse.setProcessingTime(Duration.ofMillis(100)); + fallbackResponse.setConfidence(0.6); // Lower confidence for fallback + + return Uni.createFrom().item(fallbackResponse); + } + + public Uni> getAvailableProviders() { + return Uni.createFrom() + .item( + providerRegistry.getAllProviders().stream() + .map(AIProvider::getProviderId) + .collect(Collectors.toList())); + } + + public Uni validateProvider(AIProviderConfig config) { + try { + AIProvider provider = providerRegistry.getProvider(config.getProviderId()); + return provider.validateConfiguration(config); + } catch (Exception e) { + ValidationResult result = new ValidationResult(); + result.setValid(false); + result.setProviderId(config.getProviderId()); + result.setMessage("Provider not found: " + e.getMessage()); + return Uni.createFrom().item(result); + } + } + + private AIResponse enrichResponse(AIResponse response, AnalysisResult analysisResult) { + // add any additional metadata or processing + response.setGeneratedAt(Instant.now()); + + // add correlation with analysis metadata + if (response.getMetadata() == null) { + response.setMetadata( + java.util.Map.of( + "analysisId", + analysisResult.getAnalysisId(), + "eventCount", + analysisResult.getEvents() != null + ? analysisResult.getEvents().size() + : 0)); + } else { + response.getMetadata().put("analysisId", analysisResult.getAnalysisId()); + response.getMetadata() + .put( + "eventCount", + analysisResult.getEvents() != null + ? analysisResult.getEvents().size() + : 0); + } + + return response; + } + + private String buildBasicExplanation(AnalysisResult analysisResult) { + StringBuilder explanation = new StringBuilder(); + + explanation.append("Pod failure analysis (pattern-based fallback): "); + + if (analysisResult.getEvents() != null && !analysisResult.getEvents().isEmpty()) { + // Get the first critical event + var firstEvent = analysisResult.getEvents().get(0); + + // Access pattern ID and severity through the matched pattern + if (firstEvent.getMatchedPattern() != null) { + String patternId = firstEvent.getMatchedPattern().getId(); + String severity = firstEvent.getMatchedPattern().getSeverity(); + + explanation + .append("The pod appears to have failed due to pattern '") + .append(patternId != null ? patternId : "unknown") + .append("' with severity ") + .append(severity != null ? severity : "unknown") + .append(". "); + } else { + explanation + .append("The pod appears to have failed with score ") + .append(firstEvent.getScore()) + .append(" at line ") + .append(firstEvent.getLineNumber()) + .append(". "); + } + + if (analysisResult.getEvents().size() > 1) { + explanation + .append("Additional ") + .append(analysisResult.getEvents().size() - 1) + .append(" event(s) were also detected."); + } + } else { + explanation.append("No specific failure patterns were detected in the log analysis."); + } + + return explanation.toString(); + } +} diff --git a/src/main/java/com/redhat/podmortem/ai/service/ProviderRegistry.java b/src/main/java/com/redhat/podmortem/ai/service/ProviderRegistry.java new file mode 100644 index 0000000..4c56608 --- /dev/null +++ b/src/main/java/com/redhat/podmortem/ai/service/ProviderRegistry.java @@ -0,0 +1,58 @@ +package com.redhat.podmortem.ai.service; + +import com.redhat.podmortem.common.model.provider.AIProvider; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.jboss.logging.Logger; + +@ApplicationScoped +public class ProviderRegistry { + + private static final Logger LOG = Logger.getLogger(ProviderRegistry.class); + + @Inject Instance providerInstances; + + private Map providers; + + @PostConstruct + void initializeProviders() { + this.providers = new ConcurrentHashMap<>(); + + for (AIProvider provider : providerInstances) { + providers.put(provider.getProviderId(), provider); + LOG.infof("Registered AI provider: %s", provider.getProviderId()); + } + + LOG.infof("AI Provider Registry initialized with %d providers", providers.size()); + } + + public AIProvider getProvider(String providerId) { + AIProvider provider = providers.get(providerId); + if (provider == null) { + throw new IllegalArgumentException( + "Unknown AI provider: " + + providerId + + ". Available providers: " + + providers.keySet()); + } + return provider; + } + + public List getAllProviders() { + return new ArrayList<>(providers.values()); + } + + public boolean isProviderAvailable(String providerId) { + return providers.containsKey(providerId); + } + + public List getAvailableProviderIds() { + return new ArrayList<>(providers.keySet()); + } +} diff --git a/src/main/resources/settings.xml b/src/main/resources/settings.xml index 4165079..2d987cd 100644 --- a/src/main/resources/settings.xml +++ b/src/main/resources/settings.xml @@ -4,7 +4,12 @@ https://maven.apache.org/xsd/settings-1.0.0.xsd"> - github + github-common + ${env.GITHUB_USER} + ${env.GITHUB_TOKEN} + + + github-ai-provider ${env.GITHUB_USER} ${env.GITHUB_TOKEN} diff --git a/src/test/java/com/redhat/podmortem/GreetingResourceIT.java b/src/test/java/com/redhat/podmortem/GreetingResourceIT.java deleted file mode 100644 index 13be3ba..0000000 --- a/src/test/java/com/redhat/podmortem/GreetingResourceIT.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.redhat.podmortem; - -import io.quarkus.test.junit.QuarkusIntegrationTest; - -@QuarkusIntegrationTest -class GreetingResourceIT extends GreetingResourceTest { - // Execute the same tests but in packaged mode. -} diff --git a/src/test/java/com/redhat/podmortem/GreetingResourceTest.java b/src/test/java/com/redhat/podmortem/GreetingResourceTest.java deleted file mode 100644 index 7dd6779..0000000 --- a/src/test/java/com/redhat/podmortem/GreetingResourceTest.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.redhat.podmortem; - -import static io.restassured.RestAssured.given; -import static org.hamcrest.CoreMatchers.is; - -import io.quarkus.test.junit.QuarkusTest; -import org.junit.jupiter.api.Test; - -@QuarkusTest -class GreetingResourceTest { - @Test - void testHelloEndpoint() { - given().when().get("/hello").then().statusCode(200).body(is("Hello from Quarkus REST")); - } -} From 8900a945e66905e5f8b275598e87bb3b5fdf94cd Mon Sep 17 00:00:00 2001 From: Caleb Evans Date: Wed, 25 Jun 2025 16:23:02 +0000 Subject: [PATCH 2/2] chore(ci): add package read perms to the test build --- .github/workflows/build.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6282f7c..3810612 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,6 +13,9 @@ jobs: name: Test Build on Pull Request if: github.event_name == 'pull_request' runs-on: ubuntu-latest + permissions: + contents: read + packages: read steps: - name: Checkout repository uses: actions/checkout@v4