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.