feat: Spring Boot backend with deploy pipeline

- REST API: POST/GET/DELETE /api/apps
- Build pipeline: JAR → Dockerfile → docker build → push → kubectl apply
- In-memory state with K8s reconciliation on startup
- Shell executor for docker/kubectl commands
- Configurable via env vars (server URL, bootstrap token, registry, namespace)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-03 00:16:18 +02:00
parent 9aae7fba79
commit 6e0a088183
27 changed files with 655 additions and 0 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

43
pom.xml Normal file
View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.3</version>
<relativePath/>
</parent>
<groupId>com.cameleer</groupId>
<artifactId>cameleer-deploy-demo</artifactId>
<version>0.1.0-SNAPSHOT</version>
<name>Cameleer Deploy Demo</name>
<description>Demo: upload Camel JARs, build containers with agent injection, deploy to K8s</description>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,11 @@
package com.cameleer.deploy;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DeployDemoApplication {
public static void main(String[] args) {
SpringApplication.run(DeployDemoApplication.class, args);
}
}

View File

@@ -0,0 +1,33 @@
package com.cameleer.deploy.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "cameleer.deploy")
public class DeployProperties {
private String serverUrl = "http://cameleer3-server.cameleer.svc:8081";
private String bootstrapToken = "changeme";
private String registry = "gitea.siegeln.net/cameleer/demo-apps";
private String agentMavenUrl;
private String demoNamespace = "cameleer-demo";
private String cameleerServerUi = "http://localhost:8081";
public String getServerUrl() { return serverUrl; }
public void setServerUrl(String serverUrl) { this.serverUrl = serverUrl; }
public String getBootstrapToken() { return bootstrapToken; }
public void setBootstrapToken(String bootstrapToken) { this.bootstrapToken = bootstrapToken; }
public String getRegistry() { return registry; }
public void setRegistry(String registry) { this.registry = registry; }
public String getAgentMavenUrl() { return agentMavenUrl; }
public void setAgentMavenUrl(String agentMavenUrl) { this.agentMavenUrl = agentMavenUrl; }
public String getDemoNamespace() { return demoNamespace; }
public void setDemoNamespace(String demoNamespace) { this.demoNamespace = demoNamespace; }
public String getCameleerServerUi() { return cameleerServerUi; }
public void setCameleerServerUi(String cameleerServerUi) { this.cameleerServerUi = cameleerServerUi; }
}

View File

@@ -0,0 +1,16 @@
package com.cameleer.deploy.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*");
}
}

View File

@@ -0,0 +1,94 @@
package com.cameleer.deploy.controller;
import com.cameleer.deploy.config.DeployProperties;
import com.cameleer.deploy.model.DeployRequest;
import com.cameleer.deploy.model.DeployedApp;
import com.cameleer.deploy.service.DeployService;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/apps")
public class DeployController {
private final DeployService deployService;
private final DeployProperties props;
private final ObjectMapper objectMapper;
public DeployController(DeployService deployService, DeployProperties props, ObjectMapper objectMapper) {
this.deployService = deployService;
this.props = props;
this.objectMapper = objectMapper;
}
@GetMapping
public List<DeployedApp> list() {
return deployService.listApps();
}
@GetMapping("/{name}")
public ResponseEntity<DeployedApp> get(@PathVariable String name) {
return deployService.getApp(name)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/{name}/logs")
public List<String> logs(@PathVariable String name) {
return deployService.getBuildLog(name);
}
@PostMapping
public ResponseEntity<DeployedApp> deploy(
@RequestParam("name") String name,
@RequestParam("jar") MultipartFile jar,
@RequestParam(value = "cpuRequest", required = false) String cpuRequest,
@RequestParam(value = "memoryRequest", required = false) String memoryRequest,
@RequestParam(value = "cpuLimit", required = false) String cpuLimit,
@RequestParam(value = "memoryLimit", required = false) String memoryLimit,
@RequestParam(value = "envVars", required = false) String envVarsJson
) {
try {
if (!name.matches("^[a-z0-9]([a-z0-9-]*[a-z0-9])?$")) {
return ResponseEntity.badRequest().build();
}
if (jar.isEmpty() || !jar.getOriginalFilename().endsWith(".jar")) {
return ResponseEntity.badRequest().build();
}
Map<String, String> envVars = Map.of();
if (envVarsJson != null && !envVarsJson.isBlank()) {
envVars = objectMapper.readValue(envVarsJson, new TypeReference<>() {});
}
var request = new DeployRequest(name, cpuRequest, memoryRequest, cpuLimit, memoryLimit, envVars);
var app = deployService.deploy(name, jar, request);
return ResponseEntity.accepted().body(app);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
} catch (Exception e) {
return ResponseEntity.internalServerError().build();
}
}
@DeleteMapping("/{name}")
public ResponseEntity<Void> undeploy(@PathVariable String name) {
try {
deployService.undeploy(name);
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@GetMapping("/config")
public Map<String, String> config() {
return Map.of("cameleerServerUi", props.getCameleerServerUi());
}
}

View File

@@ -0,0 +1,12 @@
package com.cameleer.deploy.model;
import java.util.Map;
public record DeployRequest(
String name,
String cpuRequest,
String memoryRequest,
String cpuLimit,
String memoryLimit,
Map<String, String> envVars
) {}

View File

@@ -0,0 +1,11 @@
package com.cameleer.deploy.model;
public enum DeployStatus {
BUILDING,
PUSHING,
DEPLOYING,
RUNNING,
PENDING,
FAILED,
DELETED
}

View File

@@ -0,0 +1,23 @@
package com.cameleer.deploy.model;
import java.time.Instant;
import java.util.Map;
public record DeployedApp(
String name,
String imageName,
String imageTag,
DeployStatus status,
String statusMessage,
ResourceConfig resources,
Map<String, String> envVars,
Instant createdAt
) {
public DeployedApp withStatus(DeployStatus status, String message) {
return new DeployedApp(name, imageName, imageTag, status, message, resources, envVars, createdAt);
}
public DeployedApp withImage(String imageName, String imageTag) {
return new DeployedApp(name, imageName, imageTag, status, statusMessage, resources, envVars, createdAt);
}
}

View File

@@ -0,0 +1,12 @@
package com.cameleer.deploy.model;
public record ResourceConfig(
String cpuRequest,
String memoryRequest,
String cpuLimit,
String memoryLimit
) {
public static ResourceConfig defaults() {
return new ResourceConfig("250m", "256Mi", "500m", "512Mi");
}
}

View File

@@ -0,0 +1,312 @@
package com.cameleer.deploy.service;
import com.cameleer.deploy.config.DeployProperties;
import com.cameleer.deploy.model.*;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class DeployService {
private static final Logger log = LoggerFactory.getLogger(DeployService.class);
private final ConcurrentHashMap<String, DeployedApp> apps = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, List<String>> buildLogs = new ConcurrentHashMap<>();
private final DeployProperties props;
private final ShellExecutor shell;
public DeployService(DeployProperties props, ShellExecutor shell) {
this.props = props;
this.shell = shell;
}
@PostConstruct
public void rediscoverApps() {
try {
String output = shell.exec("kubectl", "get", "deployments",
"-n", props.getDemoNamespace(),
"-l", "app.kubernetes.io/managed-by=cameleer-deploy",
"-o", "jsonpath={range .items[*]}{.metadata.name}|{.spec.template.spec.containers[0].image}|{.metadata.creationTimestamp}{\"\\n\"}{end}");
for (String line : output.split("\n")) {
line = line.trim();
if (line.isEmpty()) continue;
String[] parts = line.split("\\|");
if (parts.length < 3) continue;
String name = parts[0];
String fullImage = parts[1];
String created = parts[2];
String imageName = fullImage.contains(":") ? fullImage.substring(0, fullImage.lastIndexOf(':')) : fullImage;
String imageTag = fullImage.contains(":") ? fullImage.substring(fullImage.lastIndexOf(':') + 1) : "latest";
apps.put(name, new DeployedApp(
name, imageName, imageTag,
DeployStatus.RUNNING, null,
ResourceConfig.defaults(),
Map.of(),
Instant.parse(created)
));
log.info("Rediscovered app: {}", name);
}
} catch (Exception e) {
log.warn("Could not rediscover apps from namespace {}: {}", props.getDemoNamespace(), e.getMessage());
}
}
public List<DeployedApp> listApps() {
reconcileStatuses();
return apps.values().stream()
.filter(a -> a.status() != DeployStatus.DELETED)
.sorted(Comparator.comparing(DeployedApp::createdAt).reversed())
.toList();
}
public Optional<DeployedApp> getApp(String name) {
return Optional.ofNullable(apps.get(name));
}
public List<String> getBuildLog(String name) {
return buildLogs.getOrDefault(name, List.of());
}
public DeployedApp deploy(String name, MultipartFile jarFile, DeployRequest request) throws IOException {
if (apps.containsKey(name) && apps.get(name).status() != DeployStatus.DELETED) {
throw new IllegalArgumentException("App '" + name + "' already exists");
}
var resources = new ResourceConfig(
request.cpuRequest() != null ? request.cpuRequest() : "250m",
request.memoryRequest() != null ? request.memoryRequest() : "256Mi",
request.cpuLimit() != null ? request.cpuLimit() : "500m",
request.memoryLimit() != null ? request.memoryLimit() : "512Mi"
);
var envVars = request.envVars() != null ? request.envVars() : Map.<String, String>of();
String imageTag = String.valueOf(System.currentTimeMillis());
String imageName = props.getRegistry() + "/" + name;
var app = new DeployedApp(name, imageName, imageTag,
DeployStatus.BUILDING, null, resources, envVars, Instant.now());
apps.put(name, app);
buildLogs.put(name, Collections.synchronizedList(new ArrayList<>()));
// Run pipeline async
Thread.ofVirtual().name("deploy-" + name).start(() -> {
try {
runPipeline(name, jarFile.getBytes(), imageName, imageTag, resources, envVars);
} catch (Exception e) {
log.error("Deploy pipeline failed for {}", name, e);
appendLog(name, "FATAL: " + e.getMessage());
apps.computeIfPresent(name, (k, v) -> v.withStatus(DeployStatus.FAILED, e.getMessage()));
}
});
return app;
}
public void undeploy(String name) {
var app = apps.get(name);
if (app == null) throw new IllegalArgumentException("App '" + name + "' not found");
try {
appendLog(name, "Deleting deployment...");
shell.exec("kubectl", "delete", "deployment", name,
"-n", props.getDemoNamespace(), "--ignore-not-found=true");
appendLog(name, "Deployment deleted.");
apps.computeIfPresent(name, (k, v) -> v.withStatus(DeployStatus.DELETED, "Undeployed"));
} catch (Exception e) {
log.error("Failed to undeploy {}", name, e);
throw new RuntimeException("Failed to undeploy: " + e.getMessage());
}
}
private void runPipeline(String name, byte[] jarBytes, String imageName,
String imageTag, ResourceConfig resources,
Map<String, String> envVars) throws Exception {
Path buildDir = Files.createTempDirectory("cameleer-build-" + name);
try {
// 1. Write JAR
appendLog(name, "Saving JAR file...");
Path jarPath = buildDir.resolve("app.jar");
Files.write(jarPath, jarBytes);
appendLog(name, "JAR saved (" + jarBytes.length / 1024 + " KB)");
// 2. Generate Dockerfile
appendLog(name, "Generating Dockerfile...");
String dockerfile = generateDockerfile(name);
Files.writeString(buildDir.resolve("Dockerfile"), dockerfile);
appendLog(name, "Dockerfile generated");
// 3. Build image
String fullTag = imageName + ":" + imageTag;
appendLog(name, "Building image: " + fullTag);
apps.computeIfPresent(name, (k, v) -> v.withStatus(DeployStatus.BUILDING, "docker build"));
String buildOutput = shell.exec("docker", "build", "-t", fullTag, buildDir.toString());
for (String line : buildOutput.split("\n")) {
appendLog(name, "[build] " + line);
}
appendLog(name, "Image built successfully");
// 4. Push image
appendLog(name, "Pushing image to registry...");
apps.computeIfPresent(name, (k, v) -> v.withStatus(DeployStatus.PUSHING, "docker push"));
String pushOutput = shell.exec("docker", "push", fullTag);
for (String line : pushOutput.split("\n")) {
if (!line.isBlank()) appendLog(name, "[push] " + line);
}
appendLog(name, "Image pushed successfully");
// 5. Deploy to K8s
appendLog(name, "Deploying to Kubernetes...");
apps.computeIfPresent(name, (k, v) -> v.withStatus(DeployStatus.DEPLOYING, "kubectl apply"));
String manifest = generateManifest(name, fullTag, resources, envVars);
Path manifestPath = buildDir.resolve("deployment.yaml");
Files.writeString(manifestPath, manifest);
String applyOutput = shell.exec("kubectl", "apply", "-f", manifestPath.toString());
appendLog(name, "[kubectl] " + applyOutput.trim());
apps.computeIfPresent(name, (k, v) -> v
.withImage(imageName, imageTag)
.withStatus(DeployStatus.PENDING, "Waiting for pod to start"));
appendLog(name, "Deployment applied. Waiting for pod...");
} finally {
// Cleanup
try {
Files.walk(buildDir)
.sorted(Comparator.reverseOrder())
.forEach(p -> { try { Files.delete(p); } catch (IOException ignored) {} });
} catch (IOException ignored) {}
}
}
private String generateDockerfile(String appName) {
return """
FROM eclipse-temurin:21-jre-alpine
ADD %s /opt/cameleer/agent.jar
COPY app.jar /opt/app/app.jar
ENV CAMELEER_SERVER_URL=%s
ENV CAMELEER_APP_NAME=%s
ENV CAMELEER_AUTH_TOKEN=%s
ENTRYPOINT ["java", "-javaagent:/opt/cameleer/agent.jar", "-jar", "/opt/app/app.jar"]
""".formatted(
props.getAgentMavenUrl(),
props.getServerUrl(),
appName,
props.getBootstrapToken()
);
}
private String generateManifest(String name, String image,
ResourceConfig resources,
Map<String, String> envVars) {
var envLines = new StringBuilder();
// Standard cameleer env vars are baked into the image, add user-provided ones
for (var entry : envVars.entrySet()) {
envLines.append("""
- name: %s
value: "%s"
""".formatted(entry.getKey(), entry.getValue().replace("\"", "\\\"")));
}
return """
apiVersion: apps/v1
kind: Deployment
metadata:
name: %s
namespace: %s
labels:
app.kubernetes.io/managed-by: cameleer-deploy
cameleer/app-name: %s
spec:
replicas: 1
selector:
matchLabels:
app: %s
template:
metadata:
labels:
app: %s
cameleer/app-name: %s
spec:
containers:
- name: app
image: %s
resources:
requests:
cpu: %s
memory: %s
limits:
cpu: %s
memory: %s
env:
%s ports:
- containerPort: 8080
""".formatted(
name, props.getDemoNamespace(), name,
name, name, name,
image,
resources.cpuRequest(), resources.memoryRequest(),
resources.cpuLimit(), resources.memoryLimit(),
envLines.toString()
);
}
private void reconcileStatuses() {
for (var entry : apps.entrySet()) {
var app = entry.getValue();
if (app.status() == DeployStatus.DELETED || app.status() == DeployStatus.BUILDING
|| app.status() == DeployStatus.PUSHING) {
continue;
}
try {
String phase = shell.exec("kubectl", "get", "pods",
"-n", props.getDemoNamespace(),
"-l", "app=" + app.name(),
"-o", "jsonpath={.items[0].status.phase}").trim();
DeployStatus newStatus = switch (phase) {
case "Running" -> DeployStatus.RUNNING;
case "Pending" -> DeployStatus.PENDING;
case "Failed", "Unknown" -> DeployStatus.FAILED;
default -> app.status();
};
if (newStatus != app.status()) {
apps.computeIfPresent(entry.getKey(),
(k, v) -> v.withStatus(newStatus, phase));
}
} catch (Exception e) {
// Pod might not exist yet
}
}
}
private void appendLog(String name, String line) {
var logList = buildLogs.get(name);
if (logList != null) {
logList.add("[" + Instant.now() + "] " + line);
}
}
}

View File

@@ -0,0 +1,32 @@
package com.cameleer.deploy.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
@Component
public class ShellExecutor {
private static final Logger log = LoggerFactory.getLogger(ShellExecutor.class);
public String exec(String... command) throws IOException, InterruptedException {
log.debug("Executing: {}", String.join(" ", command));
var pb = new ProcessBuilder(command)
.redirectErrorStream(true);
var process = pb.start();
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
boolean finished = process.waitFor(5, TimeUnit.MINUTES);
if (!finished) {
process.destroyForcibly();
throw new RuntimeException("Command timed out: " + String.join(" ", command));
}
if (process.exitValue() != 0) {
throw new RuntimeException("Command failed (exit " + process.exitValue() + "): " + output);
}
return output;
}
}

View File

@@ -0,0 +1,17 @@
server:
port: 8082
spring:
servlet:
multipart:
max-file-size: 200MB
max-request-size: 200MB
cameleer:
deploy:
server-url: ${CAMELEER_SERVER_URL:http://cameleer3-server.cameleer.svc:8081}
bootstrap-token: ${CAMELEER_BOOTSTRAP_TOKEN:changeme}
registry: ${CAMELEER_REGISTRY:gitea.siegeln.net/cameleer/demo-apps}
agent-maven-url: ${CAMELEER_AGENT_MAVEN_URL:https://gitea.siegeln.net/api/packages/cameleer/maven/com/cameleer3/cameleer3-agent/1.0-SNAPSHOT/cameleer3-agent-1.0-SNAPSHOT.jar}
demo-namespace: ${CAMELEER_DEMO_NAMESPACE:cameleer-demo}
cameleer-server-ui: ${CAMELEER_SERVER_UI:http://localhost:8081}

View File

@@ -0,0 +1,17 @@
server:
port: 8082
spring:
servlet:
multipart:
max-file-size: 200MB
max-request-size: 200MB
cameleer:
deploy:
server-url: ${CAMELEER_SERVER_URL:http://cameleer3-server.cameleer.svc:8081}
bootstrap-token: ${CAMELEER_BOOTSTRAP_TOKEN:changeme}
registry: ${CAMELEER_REGISTRY:gitea.siegeln.net/cameleer/demo-apps}
agent-maven-url: ${CAMELEER_AGENT_MAVEN_URL:https://gitea.siegeln.net/api/packages/cameleer/maven/com/cameleer3/cameleer3-agent/1.0-SNAPSHOT/cameleer3-agent-1.0-SNAPSHOT.jar}
demo-namespace: ${CAMELEER_DEMO_NAMESPACE:cameleer-demo}
cameleer-server-ui: ${CAMELEER_SERVER_UI:http://localhost:8081}

View File

@@ -0,0 +1,11 @@
com\cameleer\deploy\config\WebConfig.class
com\cameleer\deploy\controller\DeployController$1.class
com\cameleer\deploy\controller\DeployController.class
com\cameleer\deploy\DeployDemoApplication.class
com\cameleer\deploy\model\DeployedApp.class
com\cameleer\deploy\service\DeployService.class
com\cameleer\deploy\config\DeployProperties.class
com\cameleer\deploy\model\DeployStatus.class
com\cameleer\deploy\service\ShellExecutor.class
com\cameleer\deploy\model\ResourceConfig.class
com\cameleer\deploy\model\DeployRequest.class

View File

@@ -0,0 +1,10 @@
C:\Users\Hendrik\Documents\projects\cameleer-deploy-demo\src\main\java\com\cameleer\deploy\config\DeployProperties.java
C:\Users\Hendrik\Documents\projects\cameleer-deploy-demo\src\main\java\com\cameleer\deploy\config\WebConfig.java
C:\Users\Hendrik\Documents\projects\cameleer-deploy-demo\src\main\java\com\cameleer\deploy\controller\DeployController.java
C:\Users\Hendrik\Documents\projects\cameleer-deploy-demo\src\main\java\com\cameleer\deploy\DeployDemoApplication.java
C:\Users\Hendrik\Documents\projects\cameleer-deploy-demo\src\main\java\com\cameleer\deploy\model\DeployedApp.java
C:\Users\Hendrik\Documents\projects\cameleer-deploy-demo\src\main\java\com\cameleer\deploy\model\DeployRequest.java
C:\Users\Hendrik\Documents\projects\cameleer-deploy-demo\src\main\java\com\cameleer\deploy\model\DeployStatus.java
C:\Users\Hendrik\Documents\projects\cameleer-deploy-demo\src\main\java\com\cameleer\deploy\model\ResourceConfig.java
C:\Users\Hendrik\Documents\projects\cameleer-deploy-demo\src\main\java\com\cameleer\deploy\service\DeployService.java
C:\Users\Hendrik\Documents\projects\cameleer-deploy-demo\src\main\java\com\cameleer\deploy\service\ShellExecutor.java