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:
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
43
pom.xml
Normal file
43
pom.xml
Normal 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>
|
||||||
11
src/main/java/com/cameleer/deploy/DeployDemoApplication.java
Normal file
11
src/main/java/com/cameleer/deploy/DeployDemoApplication.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
16
src/main/java/com/cameleer/deploy/config/WebConfig.java
Normal file
16
src/main/java/com/cameleer/deploy/config/WebConfig.java
Normal 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("*");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/main/java/com/cameleer/deploy/model/DeployRequest.java
Normal file
12
src/main/java/com/cameleer/deploy/model/DeployRequest.java
Normal 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
|
||||||
|
) {}
|
||||||
11
src/main/java/com/cameleer/deploy/model/DeployStatus.java
Normal file
11
src/main/java/com/cameleer/deploy/model/DeployStatus.java
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package com.cameleer.deploy.model;
|
||||||
|
|
||||||
|
public enum DeployStatus {
|
||||||
|
BUILDING,
|
||||||
|
PUSHING,
|
||||||
|
DEPLOYING,
|
||||||
|
RUNNING,
|
||||||
|
PENDING,
|
||||||
|
FAILED,
|
||||||
|
DELETED
|
||||||
|
}
|
||||||
23
src/main/java/com/cameleer/deploy/model/DeployedApp.java
Normal file
23
src/main/java/com/cameleer/deploy/model/DeployedApp.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/main/java/com/cameleer/deploy/model/ResourceConfig.java
Normal file
12
src/main/java/com/cameleer/deploy/model/ResourceConfig.java
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
312
src/main/java/com/cameleer/deploy/service/DeployService.java
Normal file
312
src/main/java/com/cameleer/deploy/service/DeployService.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/main/java/com/cameleer/deploy/service/ShellExecutor.java
Normal file
32
src/main/java/com/cameleer/deploy/service/ShellExecutor.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/main/resources/application.yml
Normal file
17
src/main/resources/application.yml
Normal 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}
|
||||||
17
target/classes/application.yml
Normal file
17
target/classes/application.yml
Normal 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}
|
||||||
BIN
target/classes/com/cameleer/deploy/DeployDemoApplication.class
Normal file
BIN
target/classes/com/cameleer/deploy/DeployDemoApplication.class
Normal file
Binary file not shown.
BIN
target/classes/com/cameleer/deploy/config/DeployProperties.class
Normal file
BIN
target/classes/com/cameleer/deploy/config/DeployProperties.class
Normal file
Binary file not shown.
BIN
target/classes/com/cameleer/deploy/config/WebConfig.class
Normal file
BIN
target/classes/com/cameleer/deploy/config/WebConfig.class
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
target/classes/com/cameleer/deploy/model/DeployRequest.class
Normal file
BIN
target/classes/com/cameleer/deploy/model/DeployRequest.class
Normal file
Binary file not shown.
BIN
target/classes/com/cameleer/deploy/model/DeployStatus.class
Normal file
BIN
target/classes/com/cameleer/deploy/model/DeployStatus.class
Normal file
Binary file not shown.
BIN
target/classes/com/cameleer/deploy/model/DeployedApp.class
Normal file
BIN
target/classes/com/cameleer/deploy/model/DeployedApp.class
Normal file
Binary file not shown.
BIN
target/classes/com/cameleer/deploy/model/ResourceConfig.class
Normal file
BIN
target/classes/com/cameleer/deploy/model/ResourceConfig.class
Normal file
Binary file not shown.
BIN
target/classes/com/cameleer/deploy/service/DeployService.class
Normal file
BIN
target/classes/com/cameleer/deploy/service/DeployService.class
Normal file
Binary file not shown.
BIN
target/classes/com/cameleer/deploy/service/ShellExecutor.class
Normal file
BIN
target/classes/com/cameleer/deploy/service/ShellExecutor.class
Normal file
Binary file not shown.
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user