GitOps is a modern approach to continuous delivery that uses Git as the single source of truth for infrastructure and application deployment. Flux is a popular GitOps tool that automates synchronizing your cluster state with your Git repository.
Core Concepts
What is GitOps?
- Git as the single source of truth for both code and infrastructure
- Automated synchronization between Git and runtime environments
- Declarative configuration management
- Automated rollbacks and disaster recovery
Why Use Flux?
- CNCF graduated project with strong community support
- Multi-tenancy and security features
- Support for multiple Git providers
- Helm, Kustomize, and plain YAML support
- Automated dependency management
Architecture Overview
Git Repository (Source of Truth) ↓ Flux Controller (Operator in Kubernetes) ↓ Kubernetes Cluster (Runtime Environment) ↓ Java Application (Deployed via GitOps)
Dependencies and Setup
1. Maven Dependencies
<properties> <spring-boot.version>3.1.0</spring-boot.version> <kubernetes-client.version>6.7.2</kubernetes-client.version> <jackson.version>2.15.2</jackson.version> </properties> <dependencies> <!-- Spring Boot --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>${spring-boot.version}</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> <version>${spring-boot.version}</version> </dependency> <!-- Kubernetes Client --> <dependency> <groupId>io.kubernetes</groupId> <artifactId>client-java</artifactId> <version>${kubernetes-client.version}</version> </dependency> <!-- YAML Processing --> <dependency> <groupId>com.fasterxml.jackson.dataformat</groupId> <artifactId>jackson-dataformat-yaml</artifactId> <version>${jackson.version}</version> </dependency> <!-- Git Integration --> <dependency> <groupId>org.eclipse.jgit</groupId> <artifactId>org.eclipse.jgit</artifactId> <version>6.6.0.202305301015-r</version> </dependency> <!-- HTTP Client for Git API --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> <version>${spring-boot.version}</version> </dependency> </dependencies> 2. Flux Installation (Prerequisite)
# Install Flux CLI curl -s https://fluxcd.io/install.sh | sudo bash # Bootstrap Flux in Kubernetes cluster flux bootstrap github \ --owner=your-username \ --repository=gitops-repo \ --branch=main \ --path=./clusters/production \ --personal
Core Implementation
1. Configuration Properties
@ConfigurationProperties(prefix = "gitops") @Data public class GitOpsProperties { private Git git = new Git(); private Flux flux = new Flux(); private Kubernetes k8s = new Kubernetes(); @Data public static class Git { private String repositoryUrl; private String branch = "main"; private String path = "./manifests"; private String username; private String token; private String commitEmail = "[email protected]"; private String commitName = "GitOps Bot"; private int cloneTimeoutSeconds = 300; } @Data public static class Flux { private String namespace = "flux-system"; private boolean enabled = true; private String syncInterval = "5m"; private String healthCheckInterval = "1m"; } @Data public static class Kubernetes { private String configPath; private String namespace = "default"; private boolean inCluster = true; } } 2. Git Service for Repository Operations
@Service @Slf4j public class GitService { private final GitOpsProperties properties; private final ObjectMapper yamlMapper; public GitService(GitOpsProperties properties) { this.properties = properties; this.yamlMapper = new ObjectMapper(new YAMLFactory()); this.yamlMapper.findAndRegisterModules(); } public Path cloneRepository() throws GitAPIException, IOException { String repoUrl = properties.getGit().getRepositoryUrl(); String branch = properties.getGit().getBranch(); Path tempDir = Files.createTempDirectory("gitops-"); log.info("Cloning repository: {} branch: {} to: {}", repoUrl, branch, tempDir); CloneCommand cloneCommand = Git.cloneRepository() .setURI(repoUrl) .setDirectory(tempDir.toFile()) .setBranch(branch) .setTimeout(properties.getGit().getCloneTimeoutSeconds()); // Add credentials if provided if (properties.getGit().getUsername() != null && properties.getGit().getToken() != null) { String authUrl = repoUrl.replace( "https://", "https://" + properties.getGit().getUsername() + ":" + properties.getGit().getToken() + "@" ); cloneCommand.setURI(authUrl); } try (Git git = cloneCommand.call()) { log.info("Successfully cloned repository to: {}", tempDir); return tempDir; } } public void commitAndPushChanges(Path repoPath, String commitMessage) throws GitAPIException, IOException { try (Git git = Git.open(repoPath.toFile())) { // Add all changes git.add().addFilepattern(".").call(); // Check if there are any changes Status status = git.status().call(); if (status.getAdded().isEmpty() && status.getChanged().isEmpty() && status.getRemoved().isEmpty()) { log.info("No changes to commit"); return; } // Commit changes CommitCommand commit = git.commit() .setMessage(commitMessage) .setAuthor( properties.getGit().getCommitName(), properties.getGit().getCommitEmail() ); RevCommit revCommit = commit.call(); log.info("Committed changes: {}", revCommit.getId().getName()); // Push changes PushCommand push = git.push(); if (properties.getGit().getUsername() != null && properties.getGit().getToken() != null) { push.setCredentialsProvider(new UsernamePasswordCredentialsProvider( properties.getGit().getUsername(), properties.getGit().getToken() )); } Iterable<PushResult> results = push.call(); for (PushResult result : results) { log.info("Push result: {}", result.getMessages()); } log.info("Successfully pushed changes to remote repository"); } } public void updateKubernetesManifest(Path repoPath, String appName, KubernetesManifest manifest) throws IOException { Path manifestsDir = repoPath.resolve(properties.getGit().getPath()); Path appDir = manifestsDir.resolve(appName); Files.createDirectories(appDir); // Write deployment.yaml Path deploymentFile = appDir.resolve("deployment.yaml"); yamlMapper.writeValue(deploymentFile.toFile(), manifest.getDeployment()); // Write service.yaml Path serviceFile = appDir.resolve("service.yaml"); yamlMapper.writeValue(serviceFile.toFile(), manifest.getService()); // Write kustomization.yaml if (manifest.getKustomization() != null) { Path kustomizationFile = appDir.resolve("kustomization.yaml"); yamlMapper.writeValue(kustomizationFile.toFile(), manifest.getKustomization()); } log.info("Updated Kubernetes manifests for app: {} in directory: {}", appName, appDir); } public List<String> getChangedFiles(Path repoPath) throws GitAPIException, IOException { try (Git git = Git.open(repoPath.toFile())) { Status status = git.status().call(); List<String> changedFiles = new ArrayList<>(); changedFiles.addAll(status.getAdded()); changedFiles.addAll(status.getChanged()); changedFiles.addAll(status.getRemoved()); changedFiles.addAll(status.getModified()); return changedFiles; } } public void cleanupTempDirectory(Path tempDir) { try { Files.walk(tempDir) .sorted(Comparator.reverseOrder()) .map(Path::toFile) .forEach(File::delete); log.debug("Cleaned up temporary directory: {}", tempDir); } catch (IOException e) { log.warn("Failed to clean up temporary directory: {}", e.getMessage()); } } } 3. Kubernetes Manifest Models
@Data @Builder @NoArgsConstructor @AllArgsConstructor public class KubernetesManifest { private V1Deployment deployment; private V1Service service; private V1ConfigMap configMap; private V1Secret secret; private Kustomization kustomization; } @Data @Builder @NoArgsConstructor @AllArgsConstructor public class Kustomization { @JsonProperty("apiVersion") private String apiVersion = "kustomize.config.k8s.io/v1beta1"; @JsonProperty("kind") private String kind = "Kustomization"; private List<String> resources; private Map<String, Object> images; private Map<String, Object> replicas; public Kustomization(List<String> resources) { this.resources = resources; } } @Data @Builder @NoArgsConstructor @AllArgsConstructor public class ApplicationSpec { private String name; private String version; private String image; private int replicas = 1; private Map<String, String> environment = new HashMap<>(); private Map<String, String> labels = new HashMap<>(); private Map<String, String> annotations = new HashMap<>(); private Resources resources; private ServiceSpec service; private IngressSpec ingress; @Data @Builder @NoArgsConstructor @AllArgsConstructor public static class Resources { private String memoryRequest = "128Mi"; private String memoryLimit = "512Mi"; private String cpuRequest = "100m"; private String cpuLimit = "500m"; } @Data @Builder @NoArgsConstructor @AllArgsConstructor public static class ServiceSpec { private int port = 8080; private String type = "ClusterIP"; private Map<String, String> annotations = new HashMap<>(); } @Data @Builder @NoArgsConstructor @AllArgsConstructor public static class IngressSpec { private String host; private String path = "/"; private String pathType = "Prefix"; private Map<String, String> annotations = new HashMap<>(); private String tlsSecret; } } 4. Kubernetes Manifest Generator
@Service @Slf4j public class KubernetesManifestGenerator { public KubernetesManifest generateManifest(ApplicationSpec spec) { log.info("Generating Kubernetes manifest for application: {}", spec.getName()); V1Deployment deployment = generateDeployment(spec); V1Service service = generateService(spec); Kustomization kustomization = generateKustomization(spec); return KubernetesManifest.builder() .deployment(deployment) .service(service) .kustomization(kustomization) .build(); } private V1Deployment generateDeployment(ApplicationSpec spec) { V1Deployment deployment = new V1Deployment(); deployment.setApiVersion("apps/v1"); deployment.setKind("Deployment"); // Metadata V1ObjectMeta metadata = new V1ObjectMeta(); metadata.setName(spec.getName()); metadata.setNamespace("default"); metadata.setLabels(createLabels(spec)); metadata.setAnnotations(spec.getAnnotations()); deployment.setMetadata(metadata); // Spec V1DeploymentSpec deploymentSpec = new V1DeploymentSpec(); deploymentSpec.setReplicas(spec.getReplicas()); // Selector V1LabelSelector selector = new V1LabelSelector(); selector.setMatchLabels(createSelectorLabels(spec)); deploymentSpec.setSelector(selector); // Template V1PodTemplateSpec template = new V1PodTemplateSpec(); V1ObjectMeta templateMetadata = new V1ObjectMeta(); templateMetadata.setLabels(createSelectorLabels(spec)); template.setMetadata(templateMetadata); V1PodSpec podSpec = new V1PodSpec(); List<V1Container> containers = new ArrayList<>(); V1Container container = new V1Container(); container.setName(spec.getName()); container.setImage(spec.getImage()); container.setPorts(createContainerPorts(spec)); container.setEnv(createEnvironmentVariables(spec)); container.setResources(createResourceRequirements(spec)); // Add liveness and readiness probes container.setLivenessProbe(createLivenessProbe()); container.setReadinessProbe(createReadinessProbe()); containers.add(container); podSpec.setContainers(containers); template.setSpec(podSpec); deploymentSpec.setTemplate(template); deployment.setSpec(deploymentSpec); return deployment; } private V1Service generateService(ApplicationSpec spec) { V1Service service = new V1Service(); service.setApiVersion("v1"); service.setKind("Service"); V1ObjectMeta metadata = new V1ObjectMeta(); metadata.setName(spec.getName()); metadata.setLabels(createLabels(spec)); service.setMetadata(metadata); V1ServiceSpec serviceSpec = new V1ServiceSpec(); serviceSpec.setSelector(createSelectorLabels(spec)); serviceSpec.setType(spec.getService() != null ? spec.getService().getType() : "ClusterIP"); List<V1ServicePort> ports = new ArrayList<>(); V1ServicePort port = new V1ServicePort(); port.setPort(spec.getService() != null ? spec.getService().getPort() : 8080); port.setTargetPort(new IntOrString(8080)); port.setProtocol("TCP"); ports.add(port); serviceSpec.setPorts(ports); service.setSpec(serviceSpec); return service; } private Kustomization generateKustomization(ApplicationSpec spec) { List<String> resources = Arrays.asList("deployment.yaml", "service.yaml"); return new Kustomization(resources); } private Map<String, String> createLabels(ApplicationSpec spec) { Map<String, String> labels = new HashMap<>(); labels.put("app", spec.getName()); labels.put("version", spec.getVersion()); labels.put("managed-by", "gitops"); labels.put("created-by", "java-gitops-service"); if (spec.getLabels() != null) { labels.putAll(spec.getLabels()); } return labels; } private Map<String, String> createSelectorLabels(ApplicationSpec spec) { Map<String, String> labels = new HashMap<>(); labels.put("app", spec.getName()); return labels; } private List<V1ContainerPort> createContainerPorts(ApplicationSpec spec) { V1ContainerPort port = new V1ContainerPort(); port.setContainerPort(8080); port.setName("http"); return Arrays.asList(port); } private List<V1EnvVar> createEnvironmentVariables(ApplicationSpec spec) { List<V1EnvVar> envVars = new ArrayList<>(); // Add application-specific environment variables if (spec.getEnvironment() != null) { spec.getEnvironment().forEach((key, value) -> { V1EnvVar envVar = new V1EnvVar(); envVar.setName(key); envVar.setValue(value); envVars.add(envVar); }); } // Add standard environment variables V1EnvVar appNameVar = new V1EnvVar(); appNameVar.setName("APP_NAME"); appNameVar.setValue(spec.getName()); envVars.add(appNameVar); V1EnvVar appVersionVar = new V1EnvVar(); appVersionVar.setName("APP_VERSION"); appVersionVar.setValue(spec.getVersion()); envVars.add(appVersionVar); return envVars; } private V1ResourceRequirements createResourceRequirements(ApplicationSpec spec) { V1ResourceRequirements resources = new V1ResourceRequirements(); Map<String, Quantity> requests = new HashMap<>(); Map<String, Quantity> limits = new HashMap<>(); if (spec.getResources() != null) { requests.put("memory", new Quantity(spec.getResources().getMemoryRequest())); requests.put("cpu", new Quantity(spec.getResources().getCpuRequest())); limits.put("memory", new Quantity(spec.getResources().getMemoryLimit())); limits.put("cpu", new Quantity(spec.getResources().getCpuLimit())); } else { // Default resources requests.put("memory", new Quantity("128Mi")); requests.put("cpu", new Quantity("100m")); limits.put("memory", new Quantity("512Mi")); limits.put("cpu", new Quantity("500m")); } resources.setRequests(requests); resources.setLimits(limits); return resources; } private V1Probe createLivenessProbe() { V1Probe probe = new V1Probe(); V1HTTPGetAction httpGet = new V1HTTPGetAction(); httpGet.setPath("/actuator/health"); httpGet.setPort(new IntOrString(8080)); httpGet.setScheme("HTTP"); probe.setHttpGet(httpGet); probe.setInitialDelaySeconds(30); probe.setPeriodSeconds(10); probe.setTimeoutSeconds(5); probe.setFailureThreshold(3); return probe; } private V1Probe createReadinessProbe() { V1Probe probe = new V1Probe(); V1HTTPGetAction httpGet = new V1HTTPGetAction(); httpGet.setPath("/actuator/health"); httpGet.setPort(new IntOrString(8080)); httpGet.setScheme("HTTP"); probe.setHttpGet(httpGet); probe.setInitialDelaySeconds(5); probe.setPeriodSeconds(5); probe.setTimeoutSeconds(3); probe.setFailureThreshold(3); return probe; } } 5. Flux Service for GitOps Operations
@Service @Slf4j public class FluxService { private final GitService gitService; private final KubernetesManifestGenerator manifestGenerator; private final GitOpsProperties properties; private final ApiClient k8sClient; public FluxService(GitService gitService, KubernetesManifestGenerator manifestGenerator, GitOpsProperties properties) throws IOException { this.gitService = gitService; this.manifestGenerator = manifestGenerator; this.properties = properties; this.k8sClient = createKubernetesClient(); } public DeploymentResult deployApplication(ApplicationSpec spec) { log.info("Starting GitOps deployment for application: {}", spec.getName()); Path tempRepo = null; try { // Step 1: Clone Git repository tempRepo = gitService.cloneRepository(); // Step 2: Generate Kubernetes manifests KubernetesManifest manifest = manifestGenerator.generateManifest(spec); // Step 3: Update manifests in repository gitService.updateKubernetesManifest(tempRepo, spec.getName(), manifest); // Step 4: Commit and push changes String commitMessage = String.format("Deploy %s version %s", spec.getName(), spec.getVersion()); gitService.commitAndPushChanges(tempRepo, commitMessage); // Step 5: Wait for Flux to sync (optional) if (properties.getFlux().isEnabled()) { waitForFluxSync(spec.getName()); } log.info("Successfully deployed application via GitOps: {}", spec.getName()); return DeploymentResult.success(spec.getName(), commitMessage); } catch (Exception e) { log.error("Failed to deploy application via GitOps: {}", e.getMessage(), e); return DeploymentResult.failure(spec.getName(), e.getMessage()); } finally { if (tempRepo != null) { gitService.cleanupTempDirectory(tempRepo); } } } public RollbackResult rollbackApplication(String appName, String targetRevision) { log.info("Starting GitOps rollback for application: {} to revision: {}", appName, targetRevision); Path tempRepo = null; try { tempRepo = gitService.cloneRepository(); // In a real implementation, you would: // 1. Checkout the target revision // 2. Verify the manifests // 3. Push the changes String commitMessage = String.format("Rollback %s to revision %s", appName, targetRevision); gitService.commitAndPushChanges(tempRepo, commitMessage); log.info("Successfully rolled back application via GitOps: {}", appName); return RollbackResult.success(appName, targetRevision, commitMessage); } catch (Exception e) { log.error("Failed to rollback application via GitOps: {}", e.getMessage(), e); return RollbackResult.failure(appName, e.getMessage()); } finally { if (tempRepo != null) { gitService.cleanupTempDirectory(tempRepo); } } } public SyncStatus getSyncStatus(String appName) { try { // Use Kubernetes client to check Flux Kustomization status CustomObjectsApi customApi = new CustomObjectsApi(k8sClient); Object result = customApi.getNamespacedCustomObject( "kustomize.toolkit.fluxcd.io", "v1", properties.getFlux().getNamespace(), "kustomizations", appName ); // Parse result to extract sync status return parseSyncStatus(result); } catch (ApiException e) { log.warn("Failed to get sync status for {}: {}", appName, e.getMessage()); return SyncStatus.unknown(appName, e.getMessage()); } } public void triggerManualSync(String appName) { try { // Annotate the Kustomization to trigger manual sync CoreV1Api coreApi = new CoreV1Api(k8sClient); String annotationPath = String.format( "kustomize.kustomize.toolkit.fluxcd.io~1%s", appName); V1Patch patch = new V1Patch(String.format( "[{\"op\": \"replace\", \"path\": \"/metadata/annotations/%s\", \"value\": \"%s\"}]", annotationPath, Instant.now().toString() )); coreApi.patchNamespacedCustomObject( "kustomize.toolkit.fluxcd.io", "v1", properties.getFlux().getNamespace(), "kustomizations", appName, patch, null, null, null, null ); log.info("Triggered manual sync for: {}", appName); } catch (ApiException e) { log.error("Failed to trigger manual sync for {}: {}", appName, e.getMessage()); throw new RuntimeException("Failed to trigger sync", e); } } private void waitForFluxSync(String appName) throws InterruptedException { log.info("Waiting for Flux to sync application: {}", appName); int maxAttempts = 30; // 5 minutes with 10-second intervals int attempt = 0; while (attempt < maxAttempts) { SyncStatus status = getSyncStatus(appName); if (status.isReady() && status.isSynced()) { log.info("Flux sync completed successfully for: {}", appName); return; } if (status.hasError()) { log.warn("Flux sync has errors for {}: {}", appName, status.getMessage()); // Continue waiting or break based on your requirements } attempt++; Thread.sleep(10000); // Wait 10 seconds } log.warn("Flux sync timeout for: {}", appName); } private ApiClient createKubernetesClient() throws IOException { if (properties.getKubernetes().isInCluster()) { return Config.defaultClient(); } else { String kubeConfigPath = properties.getKubernetes().getConfigPath(); if (kubeConfigPath != null) { return ClientBuilder.kubeconfig( Config.fromFile(new File(kubeConfigPath)) ).build(); } else { return Config.defaultClient(); } } } private SyncStatus parseSyncStatus(Object customObject) { // Simplified implementation - in reality, you'd parse the custom object try { ObjectMapper mapper = new ObjectMapper(); Map<String, Object> statusMap = mapper.convertValue(customObject, Map.class); // Extract status from the custom resource return SyncStatus.ready("default", "Synced successfully"); } catch (Exception e) { return SyncStatus.unknown("unknown", e.getMessage()); } } } 6. Data Transfer Objects
@Data @Builder @NoArgsConstructor @AllArgsConstructor public class DeploymentResult { private String applicationName; private boolean success; private String message; private String commitHash; private Instant timestamp; private List<String> changedFiles; public static DeploymentResult success(String appName, String commitMessage) { return DeploymentResult.builder() .applicationName(appName) .success(true) .message("Deployment initiated successfully") .commitHash(UUID.randomUUID().toString().substring(0, 8)) .timestamp(Instant.now()) .build(); } public static DeploymentResult failure(String appName, String error) { return DeploymentResult.builder() .applicationName(appName) .success(false) .message("Deployment failed: " + error) .timestamp(Instant.now()) .build(); } } @Data @Builder @NoArgsConstructor @AllArgsConstructor public class RollbackResult { private String applicationName; private boolean success; private String message; private String targetRevision; private Instant timestamp; public static RollbackResult success(String appName, String revision, String message) { return RollbackResult.builder() .applicationName(appName) .success(true) .message(message) .targetRevision(revision) .timestamp(Instant.now()) .build(); } public static RollbackResult failure(String appName, String error) { return RollbackResult.builder() .applicationName(appName) .success(false) .message("Rollback failed: " + error) .timestamp(Instant.now()) .build(); } } @Data @Builder @NoArgsConstructor @AllArgsConstructor public class SyncStatus { private String applicationName; private boolean ready; private boolean synced; private String status; private String message; private String lastAppliedRevision; private Instant lastUpdateTime; public static SyncStatus ready(String appName, String message) { return SyncStatus.builder() .applicationName(appName) .ready(true) .synced(true) .status("Ready") .message(message) .lastUpdateTime(Instant.now()) .build(); } public static SyncStatus progressing(String appName, String message) { return SyncStatus.builder() .applicationName(appName) .ready(false) .synced(false) .status("Progressing") .message(message) .lastUpdateTime(Instant.now()) .build(); } public static SyncStatus unknown(String appName, String message) { return SyncStatus.builder() .applicationName(appName) .ready(false) .synced(false) .status("Unknown") .message(message) .lastUpdateTime(Instant.now()) .build(); } public boolean hasError() { return "Error".equals(status); } } REST API Controllers
1. GitOps Deployment Controller
@RestController @RequestMapping("/api/gitops") @Slf4j public class GitOpsController { private final FluxService fluxService; public GitOpsController(FluxService fluxService) { this.fluxService = fluxService; } @PostMapping("/deploy") public ResponseEntity<DeploymentResult> deployApplication(@RequestBody ApplicationSpec spec) { log.info("Received deployment request for application: {}", spec.getName()); try { // Validate application spec validateApplicationSpec(spec); DeploymentResult result = fluxService.deployApplication(spec); if (result.isSuccess()) { return ResponseEntity.accepted().body(result); } else { return ResponseEntity.unprocessableEntity().body(result); } } catch (ValidationException e) { log.warn("Validation failed for deployment request: {}", e.getMessage()); return ResponseEntity.badRequest() .body(DeploymentResult.failure(spec.getName(), e.getMessage())); } catch (Exception e) { log.error("Unexpected error during deployment: {}", e.getMessage(), e); return ResponseEntity.internalServerError() .body(DeploymentResult.failure(spec.getName(), "Internal server error")); } } @PostMapping("/{appName}/rollback") public ResponseEntity<RollbackResult> rollbackApplication( @PathVariable String appName, @RequestParam String revision) { log.info("Received rollback request for application: {} to revision: {}", appName, revision); try { RollbackResult result = fluxService.rollbackApplication(appName, revision); if (result.isSuccess()) { return ResponseEntity.accepted().body(result); } else { return ResponseEntity.unprocessableEntity().body(result); } } catch (Exception e) { log.error("Unexpected error during rollback: {}", e.getMessage(), e); return ResponseEntity.internalServerError() .body(RollbackResult.failure(appName, "Internal server error")); } } @GetMapping("/{appName}/status") public ResponseEntity<SyncStatus> getSyncStatus(@PathVariable String appName) { log.debug("Getting sync status for application: {}", appName); try { SyncStatus status = fluxService.getSyncStatus(appName); return ResponseEntity.ok(status); } catch (Exception e) { log.error("Failed to get sync status: {}", e.getMessage(), e); return ResponseEntity.internalServerError() .body(SyncStatus.unknown(appName, "Failed to get status")); } } @PostMapping("/{appName}/sync") public ResponseEntity<String> triggerSync(@PathVariable String appName) { log.info("Triggering manual sync for application: {}", appName); try { fluxService.triggerManualSync(appName); return ResponseEntity.accepted() .body("Manual sync triggered for: " + appName); } catch (Exception e) { log.error("Failed to trigger sync: {}", e.getMessage(), e); return ResponseEntity.internalServerError() .body("Failed to trigger sync: " + e.getMessage()); } } @GetMapping("/health") public ResponseEntity<Map<String, String>> healthCheck() { Map<String, String> health = new HashMap<>(); health.put("status", "healthy"); health.put("timestamp", Instant.now().toString()); health.put("service", "gitops-controller"); return ResponseEntity.ok(health); } private void validateApplicationSpec(ApplicationSpec spec) throws ValidationException { if (spec.getName() == null || spec.getName().trim().isEmpty()) { throw new ValidationException("Application name is required"); } if (spec.getImage() == null || spec.getImage().trim().isEmpty()) { throw new ValidationException("Container image is required"); } if (spec.getVersion() == null || spec.getVersion().trim().isEmpty()) { throw new ValidationException("Application version is required"); } if (spec.getReplicas() < 0) { throw new ValidationException("Replicas must be non-negative"); } // Validate DNS name compatibility if (!spec.getName().matches("^[a-z0-9-]+$")) { throw new ValidationException( "Application name must contain only lowercase letters, numbers, and hyphens"); } } } class ValidationException extends Exception { public ValidationException(String message) { super(message); } } 2. Application Management Controller
@RestController @RequestMapping("/api/applications") @Slf4j public class ApplicationController { private final FluxService fluxService; private final KubernetesManifestGenerator manifestGenerator; public ApplicationController(FluxService fluxService, KubernetesManifestGenerator manifestGenerator) { this.fluxService = fluxService; this.manifestGenerator = manifestGenerator; } @PostMapping("/preview") public ResponseEntity<Map<String, Object>> previewManifest(@RequestBody ApplicationSpec spec) { log.info("Generating manifest preview for application: {}", spec.getName()); try { KubernetesManifest manifest = manifestGenerator.generateManifest(spec); ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); Map<String, Object> preview = new HashMap<>(); // Convert to YAML strings for preview if (manifest.getDeployment() != null) { preview.put("deployment", mapper.writeValueAsString(manifest.getDeployment())); } if (manifest.getService() != null) { preview.put("service", mapper.writeValueAsString(manifest.getService())); } if (manifest.getKustomization() != null) { preview.put("kustomization", mapper.writeValueAsString(manifest.getKustomization())); } return ResponseEntity.ok(preview); } catch (Exception e) { log.error("Failed to generate manifest preview: {}", e.getMessage(), e); return ResponseEntity.internalServerError() .body(Map.of("error", "Failed to generate preview: " + e.getMessage())); } } @GetMapping("/templates") public ResponseEntity<Map<String, ApplicationSpec>> getApplicationTemplates() { Map<String, ApplicationSpec> templates = new HashMap<>(); // Spring Boot microservice template templates.put("spring-boot-microservice", ApplicationSpec.builder() .name("spring-boot-app") .version("1.0.0") .image("my-registry/spring-boot-app:1.0.0") .replicas(2) .environment(Map.of( "SPRING_PROFILES_ACTIVE", "production", "JAVA_OPTS", "-Xmx512m" )) .resources(ApplicationSpec.Resources.builder() .memoryRequest("256Mi") .memoryLimit("1Gi") .cpuRequest("200m") .cpuLimit("800m") .build()) .service(ApplicationSpec.ServiceSpec.builder() .port(8080) .type("ClusterIP") .build()) .build()); // React frontend template templates.put("react-frontend", ApplicationSpec.builder() .name("react-frontend") .version("1.0.0") .image("my-registry/react-app:1.0.0") .replicas(3) .resources(ApplicationSpec.Resources.builder() .memoryRequest("128Mi") .memoryLimit("256Mi") .cpuRequest("100m") .cpuLimit("300m") .build()) .service(ApplicationSpec.ServiceSpec.builder() .port(80) .type("ClusterIP") .build()) .build()); return ResponseEntity.ok(templates); } } Configuration
1. Application Properties
# application.yml gitops: git: repository-url: "https://github.com/my-org/gitops-repo.git" branch: "main" path: "./manifests" username: "${GIT_USERNAME:}" token: "${GIT_TOKEN:}" commit-email: "[email protected]" commit-name: "GitOps Bot" clone-timeout-seconds: 300 flux: enabled: true namespace: "flux-system" sync-interval: "5m" health-check-interval: "1m" kubernetes: in-cluster: true namespace: "default" config-path: "${KUBECONFIG:}" server: port: 8080 spring: application: name: "gitops-service" management: endpoints: web: exposure: include: health,info,metrics endpoint: health: show-details: always 2. Spring Boot Configuration
@Configuration @EnableConfigurationProperties(GitOpsProperties.class) @EnableScheduling public class GitOpsConfiguration { @Bean @ConditionalOnMissingBean public ObjectMapper yamlObjectMapper() { ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); mapper.findAndRegisterModules(); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); return mapper; } @Bean public RestTemplate restTemplate() { return new RestTemplate(); } @Bean public WebClient webClient() { return WebClient.builder().build(); } @Bean public TaskExecutor gitOpsTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(10); executor.setQueueCapacity(25); executor.setThreadNamePrefix("gitops-"); return executor; } } Example Git Repository Structure
gitops-repo/ ├── clusters/ │ └── production/ │ ├── flux-system/ │ └── kustomization.yaml └── manifests/ ├── my-spring-app/ │ ├── deployment.yaml │ ├── service.yaml │ └── kustomization.yaml └── my-react-app/ ├── deployment.yaml ├── service.yaml └── kustomization.yaml
Example Kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - deployment.yaml - service.yaml
Testing
1. Unit Tests
@ExtendWith(MockitoExtension.class) class FluxServiceTest { @Mock private GitService gitService; @Mock private KubernetesManifestGenerator manifestGenerator; @Mock private GitOpsProperties properties; @InjectMocks private FluxService fluxService; @Test void shouldDeployApplicationSuccessfully() throws Exception { // Given ApplicationSpec spec = createTestApplicationSpec(); KubernetesManifest manifest = createTestManifest(); Path tempRepo = Path.of("/tmp/test-repo"); when(properties.getFlux()).thenReturn(new GitOpsProperties.Flux()); when(gitService.cloneRepository()).thenReturn(tempRepo); when(manifestGenerator.generateManifest(spec)).thenReturn(manifest); // When DeploymentResult result = fluxService.deployApplication(spec); // Then assertThat(result.isSuccess()).isTrue(); assertThat(result.getApplicationName()).isEqualTo("test-app"); verify(gitService).cloneRepository(); verify(gitService).updateKubernetesManifest(tempRepo, "test-app", manifest); verify(gitService).commitAndPushChanges(tempRepo, anyString()); verify(gitService).cleanupTempDirectory(tempRepo); } private ApplicationSpec createTestApplicationSpec() { return ApplicationSpec.builder() .name("test-app") .version("1.0.0") .image("test-registry/app:1.0.0") .replicas(2) .build(); } private KubernetesManifest createTestManifest() { return KubernetesManifest.builder() .deployment(new V1Deployment()) .service(new V1Service()) .kustomization(new Kustomization()) .build(); } } @SpringBootTest class GitOpsControllerIntegrationTest { @Autowired private TestRestTemplate restTemplate; @Test void shouldDeployApplicationViaApi() { // Given ApplicationSpec spec = ApplicationSpec.builder() .name("integration-test-app") .version("1.0.0") .image("test/image:1.0.0") .replicas(1) .build(); // When ResponseEntity<DeploymentResult> response = restTemplate.postForEntity( "/api/gitops/deploy", spec, DeploymentResult.class ); // Then assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); assertThat(response.getBody()).isNotNull(); assertThat(response.getBody().isSuccess()).isTrue(); } } Best Practices
1. Security Considerations
@Service @Slf4j public class GitOpsSecurityService { public void validateDeploymentRequest(ApplicationSpec spec, String user) { // Check if user has permission to deploy if (!hasDeploymentPermission(user, spec.getName())) { throw new SecurityException("User " + user + " not authorized to deploy " + spec.getName()); } // Validate image registry if (!isAllowedImageRegistry(spec.getImage())) { throw new SecurityException("Image registry not allowed: " + spec.getImage()); } // Check resource limits validateResourceLimits(spec.getResources()); } private boolean hasDeploymentPermission(String user, String appName) { // Implement your permission logic return true; // Simplified } private boolean isAllowedImageRegistry(String image) { List<String> allowedRegistries = Arrays.asList( "my-registry.company.com", "docker.io", "ghcr.io" ); return allowedRegistries.stream().anyMatch(image::startsWith); } private void validateResourceLimits(ApplicationSpec.Resources resources) { if (resources == null) return; // Validate memory limits validateMemoryLimit(resources.getMemoryLimit()); validateCpuLimit(resources.getCpuLimit()); } private void validateMemoryLimit(String memoryLimit) { // Parse and validate memory limit (e.g., max 2Gi per pod) log.debug("Validating memory limit: {}", memoryLimit); } private void validateCpuLimit(String cpuLimit) { // Parse and validate CPU limit (e.g., max 2 CPU per pod) log.debug("Validating CPU limit: {}", cpuLimit); } } 2. Monitoring and Observability
@Component @Slf4j public class GitOpsMetrics { private final MeterRegistry meterRegistry; private final Counter deploymentCounter; private final Counter errorCounter; private final Timer deploymentTimer; public GitOpsMetrics(MeterRegistry meterRegistry) { this.meterRegistry = meterRegistry; this.deploymentCounter = Counter.builder("gitops.deployments.total") .description("Total number of GitOps deployments") .register(meterRegistry); this.errorCounter = Counter.builder("gitops.deployments.errors") .description("Number of failed GitOps deployments") .register(meterRegistry); this.deploymentTimer = Timer.builder("gitops.deployment.duration") .description("Time taken for GitOps deployments") .register(meterRegistry); } public void recordDeployment(boolean success, long duration) { deploymentCounter.increment(); deploymentTimer.record(duration, TimeUnit.MILLISECONDS); if (!success) { errorCounter.increment(); } } } @Aspect @Component @Slf4j public class GitOpsMetricsAspect { private final GitOpsMetrics metrics; public GitOpsMetricsAspect(GitOpsMetrics metrics) { this.metrics = metrics; } @Around("execution(* com.example.service.FluxService.deployApplication(..))") public Object trackDeploymentMetrics(ProceedingJoinPoint joinPoint) throws Throwable { long startTime = System.currentTimeMillis(); boolean success = false; try { Object result = joinPoint.proceed(); if (result instanceof DeploymentResult) { success = ((DeploymentResult) result).isSuccess(); } return result; } finally { long duration = System.currentTimeMillis() - startTime; metrics.recordDeployment(success, duration); } } } Conclusion
Implementing GitOps with Flux in Java provides:
- Declarative infrastructure with Git as the single source of truth
- Automated synchronization between Git and Kubernetes
- Rollback capabilities using Git history
- Security and compliance through code review processes
- Consistent environments across development, staging, and production
The Java-based GitOps service acts as a control plane that:
- Generates Kubernetes manifests from application specifications
- Manages Git repository operations for configuration changes
- Integrates with Flux for automated deployment synchronization
- Provides REST APIs for application deployment and management
- Includes monitoring and security for production readiness
This approach enables development teams to deploy applications safely while maintaining the benefits of Git-based workflows, including version control, code review, and audit trails.