From 3302bb4812fcd0a228f313a1d4b075e9f25b77fe Mon Sep 17 00:00:00 2001 From: Caleb Evans Date: Wed, 2 Jul 2025 13:39:48 +0000 Subject: [PATCH] feat(pattern lib): add pattern library reconciler and sync service along with any supporting files --- deploy/examples/sample-patternlibrary.yaml | 16 ++ pom.xml | 2 +- .../reconcile/PatternLibraryReconciler.java | 140 ++++++++++++++ .../reconcile}/PodmortemReconciler.java | 2 +- .../operator/service/PatternSyncService.java | 183 ++++++++++++++++++ src/main/kubernetes/operator-deployment.yaml | 92 +++++++++ .../kubernetes/operator-serviceaccount.yaml | 10 + src/main/kubernetes/patternlibrary-crd.yaml | 99 ++++++++++ 8 files changed, 542 insertions(+), 2 deletions(-) create mode 100644 deploy/examples/sample-patternlibrary.yaml create mode 100644 src/main/java/com/redhat/podmortem/operator/reconcile/PatternLibraryReconciler.java rename src/main/java/com/redhat/podmortem/{reconcile/config => operator/reconcile}/PodmortemReconciler.java (99%) create mode 100644 src/main/java/com/redhat/podmortem/operator/service/PatternSyncService.java create mode 100644 src/main/kubernetes/operator-deployment.yaml create mode 100644 src/main/kubernetes/operator-serviceaccount.yaml create mode 100644 src/main/kubernetes/patternlibrary-crd.yaml diff --git a/deploy/examples/sample-patternlibrary.yaml b/deploy/examples/sample-patternlibrary.yaml new file mode 100644 index 0000000..cb87b04 --- /dev/null +++ b/deploy/examples/sample-patternlibrary.yaml @@ -0,0 +1,16 @@ +--- +apiVersion: podmortem.redhat.com/v1alpha1 +kind: PatternLibrary +metadata: + name: community-patterns + namespace: podmortem-system +spec: + repositories: + - name: "podmortem-community" + url: "https://github.com/podmortem/pattern-libraries.git" + branch: "main" + refreshInterval: "1h" + enabledLibraries: + - "spring-boot-core" + - "kubernetes-events" + - "postgresql-errors" diff --git a/pom.xml b/pom.xml index c185f4d..8b554ee 100644 --- a/pom.xml +++ b/pom.xml @@ -16,7 +16,7 @@ true 3.5.3 - 1.0-1db4e13-SNAPSHOT + 1.0-36588fe-SNAPSHOT diff --git a/src/main/java/com/redhat/podmortem/operator/reconcile/PatternLibraryReconciler.java b/src/main/java/com/redhat/podmortem/operator/reconcile/PatternLibraryReconciler.java new file mode 100644 index 0000000..9cfb822 --- /dev/null +++ b/src/main/java/com/redhat/podmortem/operator/reconcile/PatternLibraryReconciler.java @@ -0,0 +1,140 @@ +package com.redhat.podmortem.operator.reconcile; + +import com.redhat.podmortem.common.model.kube.patternlibrary.PatternLibrary; +import com.redhat.podmortem.common.model.kube.patternlibrary.PatternLibraryStatus; +import com.redhat.podmortem.common.model.kube.patternlibrary.PatternRepository; +import com.redhat.podmortem.operator.service.PatternSyncService; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.time.Instant; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@ControllerConfiguration +@ApplicationScoped +public class PatternLibraryReconciler implements Reconciler { + + private static final Logger log = LoggerFactory.getLogger(PatternLibraryReconciler.class); + + @Inject KubernetesClient client; + + @Inject PatternSyncService patternSyncService; + + @Override + public UpdateControl reconcile( + PatternLibrary resource, Context context) { + log.info("Reconciling PatternLibrary: {}", resource.getMetadata().getName()); + + // Initialize status if not present + if (resource.getStatus() == null) { + resource.setStatus(new PatternLibraryStatus()); + } + + try { + // Update status to syncing + updatePatternLibraryStatus(resource, "Syncing", "Synchronizing pattern repositories"); + + // Sync each configured repository + List repositories = resource.getSpec().getRepositories(); + if (repositories != null) { + for (PatternRepository repo : repositories) { + syncRepository(resource, repo); + } + } + + // Discover available libraries from synced repositories + List availableLibraries = + patternSyncService.getAvailableLibraries(resource.getMetadata().getName()); + + // Update status with available libraries + resource.getStatus().setAvailableLibraries(availableLibraries); + updatePatternLibraryStatus( + resource, + "Ready", + String.format( + "Successfully synced %d repositories, %d libraries available", + repositories != null ? repositories.size() : 0, + availableLibraries.size())); + + return UpdateControl.patchStatus(resource); + + } catch (Exception e) { + log.error("Error reconciling PatternLibrary: {}", resource.getMetadata().getName(), e); + updatePatternLibraryStatus( + resource, "Failed", "Failed to reconcile: " + e.getMessage()); + return UpdateControl.patchStatus(resource); + } + } + + private void syncRepository(PatternLibrary resource, PatternRepository repo) { + try { + log.info( + "Syncing repository {} for PatternLibrary {}", + repo.getName(), + resource.getMetadata().getName()); + + // Get credentials from secret if specified + String credentials = null; + if (repo.getCredentials() != null && repo.getCredentials().getSecretRef() != null) { + credentials = getCredentialsFromSecret(repo.getCredentials().getSecretRef()); + } + + // Sync repository using PatternSyncService + patternSyncService.syncRepository(resource.getMetadata().getName(), repo, credentials); + + // Update repository sync status + updateRepositoryStatus(resource, repo, "Success", null); + + } catch (Exception e) { + log.error("Failed to sync repository {}: {}", repo.getName(), e.getMessage(), e); + updateRepositoryStatus(resource, repo, "Failed", e.getMessage()); + } + } + + private String getCredentialsFromSecret(String secretName) { + try { + // Get secret from the same namespace as PatternLibrary + var secret = + client.secrets() + .inNamespace("podmortem-system") // TODO: use resource namespace + .withName(secretName) + .get(); + + if (secret != null && secret.getData() != null) { + // Return base64 decoded credentials + // This is a simplified version - in practice you'd handle username/password + // or token-based auth + return new String(secret.getData().get("token")); + } + } catch (Exception e) { + log.warn("Failed to get credentials from secret {}: {}", secretName, e.getMessage()); + } + return null; + } + + private void updateRepositoryStatus( + PatternLibrary resource, PatternRepository repo, String status, String error) { + // TODO: Implement repository status tracking in PatternLibraryStatus + // This would update the syncedRepositories field with individual repo status + log.info("Repository {} status: {}", repo.getName(), status); + } + + private void updatePatternLibraryStatus(PatternLibrary resource, String phase, String message) { + PatternLibraryStatus status = resource.getStatus(); + if (status == null) { + status = new PatternLibraryStatus(); + resource.setStatus(status); + } + + status.setPhase(phase); + status.setMessage(message); + status.setLastSyncTime(Instant.now()); + status.setObservedGeneration(resource.getMetadata().getGeneration()); + } +} diff --git a/src/main/java/com/redhat/podmortem/reconcile/config/PodmortemReconciler.java b/src/main/java/com/redhat/podmortem/operator/reconcile/PodmortemReconciler.java similarity index 99% rename from src/main/java/com/redhat/podmortem/reconcile/config/PodmortemReconciler.java rename to src/main/java/com/redhat/podmortem/operator/reconcile/PodmortemReconciler.java index a12e947..eee2984 100644 --- a/src/main/java/com/redhat/podmortem/reconcile/config/PodmortemReconciler.java +++ b/src/main/java/com/redhat/podmortem/operator/reconcile/PodmortemReconciler.java @@ -1,4 +1,4 @@ -package com.redhat.podmortem.reconcile.config; +package com.redhat.podmortem.operator.reconcile; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; diff --git a/src/main/java/com/redhat/podmortem/operator/service/PatternSyncService.java b/src/main/java/com/redhat/podmortem/operator/service/PatternSyncService.java new file mode 100644 index 0000000..f8ea8c6 --- /dev/null +++ b/src/main/java/com/redhat/podmortem/operator/service/PatternSyncService.java @@ -0,0 +1,183 @@ +package com.redhat.podmortem.operator.service; + +import com.redhat.podmortem.common.model.kube.patternlibrary.PatternRepository; +import jakarta.enterprise.context.ApplicationScoped; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@ApplicationScoped +public class PatternSyncService { + + private static final Logger log = LoggerFactory.getLogger(PatternSyncService.class); + private static final String PATTERN_CACHE_DIR = "/tmp/pattern-cache"; + + public void syncRepository(String libraryName, PatternRepository repo, String credentials) { + try { + log.info("Syncing repository {} for library {}", repo.getName(), libraryName); + + // Create cache directory structure + Path libraryPath = Paths.get(PATTERN_CACHE_DIR, libraryName); + Files.createDirectories(libraryPath); + + Path repoPath = libraryPath.resolve(repo.getName()); + + if (Files.exists(repoPath)) { + // Pull latest changes + pullRepository(repoPath, repo, credentials); + } else { + // Clone repository + cloneRepository(repoPath, repo, credentials); + } + + // Validate patterns in the repository + validatePatterns(repoPath); + + log.info( + "Successfully synced repository {} for library {}", + repo.getName(), + libraryName); + + } catch (Exception e) { + log.error( + "Failed to sync repository {} for library {}: {}", + repo.getName(), + libraryName, + e.getMessage(), + e); + throw new RuntimeException("Repository sync failed", e); + } + } + + public List getAvailableLibraries(String libraryName) { + List libraries = new ArrayList<>(); + try { + Path libraryPath = Paths.get(PATTERN_CACHE_DIR, libraryName); + if (Files.exists(libraryPath)) { + // Scan for pattern library directories and files + try (Stream paths = Files.walk(libraryPath, 2)) { + paths.filter(Files::isDirectory) + .filter(path -> !path.equals(libraryPath)) + .map(path -> path.getFileName().toString()) + .forEach(libraries::add); + } + + // Also scan for YAML files that might be libraries + try (Stream yamlFiles = Files.walk(libraryPath)) { + yamlFiles + .filter(Files::isRegularFile) + .filter( + path -> + path.toString().endsWith(".yaml") + || path.toString().endsWith(".yml")) + .map( + path -> + path.getFileName() + .toString() + .replaceAll("\\.(yaml|yml)$", "")) + .forEach(libraries::add); + } + } + } catch (Exception e) { + log.error( + "Failed to get available libraries for {}: {}", libraryName, e.getMessage(), e); + } + return libraries; + } + + private void cloneRepository(Path repoPath, PatternRepository repo, String credentials) { + try { + log.info("Cloning repository {} to {}", repo.getUrl(), repoPath); + + // For now, using simple git command execution + // In a full implementation, you'd use JGit library + ProcessBuilder pb = new ProcessBuilder(); + pb.directory(repoPath.getParent().toFile()); + + if (credentials != null) { + // Handle authentication - this is simplified + String authUrl = repo.getUrl().replace("https://", "https://" + credentials + "@"); + pb.command( + "git", + "clone", + "-b", + repo.getBranch() != null ? repo.getBranch() : "main", + authUrl, + repoPath.getFileName().toString()); + } else { + pb.command( + "git", + "clone", + "-b", + repo.getBranch() != null ? repo.getBranch() : "main", + repo.getUrl(), + repoPath.getFileName().toString()); + } + + Process process = pb.start(); + int exitCode = process.waitFor(); + + if (exitCode != 0) { + throw new RuntimeException("Git clone failed with exit code: " + exitCode); + } + + } catch (Exception e) { + log.error("Failed to clone repository {}: {}", repo.getUrl(), e.getMessage(), e); + throw new RuntimeException("Git clone failed", e); + } + } + + private void pullRepository(Path repoPath, PatternRepository repo, String credentials) { + try { + log.info("Pulling latest changes for repository at {}", repoPath); + + ProcessBuilder pb = new ProcessBuilder(); + pb.directory(repoPath.toFile()); + pb.command( + "git", "pull", "origin", repo.getBranch() != null ? repo.getBranch() : "main"); + + Process process = pb.start(); + int exitCode = process.waitFor(); + + if (exitCode != 0) { + throw new RuntimeException("Git pull failed with exit code: " + exitCode); + } + + } catch (Exception e) { + log.error("Failed to pull repository at {}: {}", repoPath, e.getMessage(), e); + throw new RuntimeException("Git pull failed", e); + } + } + + private void validatePatterns(Path repoPath) { + try { + // Basic validation - check if YAML files are present + try (Stream files = Files.walk(repoPath)) { + long yamlCount = + files.filter(Files::isRegularFile) + .filter( + path -> + path.toString().endsWith(".yaml") + || path.toString().endsWith(".yml")) + .count(); + + if (yamlCount == 0) { + log.warn("No YAML pattern files found in repository at {}", repoPath); + } + + log.info("Found {} YAML pattern files in repository at {}", yamlCount, repoPath); + } + } catch (Exception e) { + log.error( + "Failed to validate patterns in repository at {}: {}", + repoPath, + e.getMessage(), + e); + } + } +} diff --git a/src/main/kubernetes/operator-deployment.yaml b/src/main/kubernetes/operator-deployment.yaml new file mode 100644 index 0000000..af86351 --- /dev/null +++ b/src/main/kubernetes/operator-deployment.yaml @@ -0,0 +1,92 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: podmortem-operator + namespace: podmortem-system + labels: + app: podmortem-operator + component: operator +spec: + replicas: 1 + selector: + matchLabels: + app: podmortem-operator + template: + metadata: + labels: + app: podmortem-operator + component: operator + spec: + serviceAccountName: podmortem-operator + securityContext: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + seccompProfile: + type: RuntimeDefault + containers: + - name: operator + image: ghcr.io/podmortem/podmortem-operator:latest + imagePullPolicy: Always + ports: + - name: http + containerPort: 8080 + protocol: TCP + - name: https + containerPort: 8443 + protocol: TCP + env: + - name: KUBERNETES_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: OPERATOR_NAME + value: "podmortem-operator" + resources: + limits: + memory: "512Mi" + cpu: "500m" + requests: + memory: "256Mi" + cpu: "100m" + livenessProbe: + httpGet: + path: /q/health/live + port: 8080 + scheme: HTTP + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /q/health/ready + port: 8080 + scheme: HTTP + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 10 + failureThreshold: 3 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + privileged: false + capabilities: + drop: + - ALL + volumeMounts: + - name: tmp + mountPath: /tmp + - name: pattern-cache + mountPath: /tmp/pattern-cache + volumes: + - name: tmp + emptyDir: {} + - name: pattern-cache + emptyDir: {} + terminationGracePeriodSeconds: 60 diff --git a/src/main/kubernetes/operator-serviceaccount.yaml b/src/main/kubernetes/operator-serviceaccount.yaml new file mode 100644 index 0000000..931aa61 --- /dev/null +++ b/src/main/kubernetes/operator-serviceaccount.yaml @@ -0,0 +1,10 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: podmortem-operator + namespace: podmortem-system + labels: + app: podmortem-operator + component: operator +automountServiceAccountToken: true diff --git a/src/main/kubernetes/patternlibrary-crd.yaml b/src/main/kubernetes/patternlibrary-crd.yaml new file mode 100644 index 0000000..368e666 --- /dev/null +++ b/src/main/kubernetes/patternlibrary-crd.yaml @@ -0,0 +1,99 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: patternlibraries.podmortem.redhat.com +spec: + group: podmortem.redhat.com + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + repositories: + type: array + items: + type: object + properties: + name: + type: string + description: "Name identifier for the repository" + url: + type: string + description: "Git repository URL containing pattern libraries" + branch: + type: string + default: "main" + description: "Git branch to use" + credentials: + type: object + properties: + secretRef: + type: string + description: "Name of Kubernetes secret containing Git credentials" + description: "Authentication credentials for private repositories" + required: ["name", "url"] + refreshInterval: + type: string + default: "1h" + description: "How often to sync patterns from repositories (e.g., '30m', '1h')" + enabledLibraries: + type: array + items: + type: string + description: "List of pattern library IDs to enable from the repositories" + status: + type: object + properties: + phase: + type: string + enum: ["Pending", "Syncing", "Ready", "Failed"] + description: "Current state of the pattern library" + message: + type: string + description: "Human-readable status message" + lastSyncTime: + type: string + format: date-time + description: "When patterns were last synchronized" + syncedRepositories: + type: array + items: + type: object + properties: + name: + type: string + lastCommit: + type: string + syncTime: + type: string + format: date-time + status: + type: string + enum: ["Success", "Failed"] + error: + type: string + description: "Status of each configured repository" + availableLibraries: + type: array + items: + type: string + description: "List of discovered pattern libraries from all repositories" + observedGeneration: + type: integer + format: int64 + subresources: + status: {} + scope: Namespaced + names: + plural: patternlibraries + singular: patternlibrary + kind: PatternLibrary + shortNames: + - pl