GitOps with Flux in Java: Implementing GitOps Practices and Automation

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:

  1. Generates Kubernetes manifests from application specifications
  2. Manages Git repository operations for configuration changes
  3. Integrates with Flux for automated deployment synchronization
  4. Provides REST APIs for application deployment and management
  5. 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.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper