992 lines
37 KiB
Markdown
992 lines
37 KiB
Markdown
|
|
# Plan 3: Runtime Management in the Server
|
||
|
|
|
||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
|
|
||
|
|
**Goal:** Move environment management, app lifecycle, JAR upload, and Docker container orchestration from the SaaS layer into the server, so the server is a self-sufficient product that can deploy and manage Camel applications.
|
||
|
|
|
||
|
|
**Architecture:** The server gains Environment/App/AppVersion/Deployment entities stored in its PostgreSQL. A `RuntimeOrchestrator` interface abstracts Docker/K8s/disabled modes, auto-detected at startup. The Docker implementation uses a shared base image + volume-mounted JARs (no per-deployment image builds). Apps are promoted between environments by creating new Deployments pointing to the same AppVersion. Routing supports both path-based and subdomain-based modes via Traefik labels.
|
||
|
|
|
||
|
|
**Tech Stack:** Java 17, Spring Boot 3.4.3, docker-java (zerodep transport), PostgreSQL 16, Flyway, JUnit 5, Testcontainers
|
||
|
|
|
||
|
|
**Repo:** `C:\Users\Hendrik\Documents\projects\cameleer3-server`
|
||
|
|
|
||
|
|
**Source reference:** Code ported from `C:\Users\Hendrik\Documents\projects\cameleer-saas` (environment, app, deployment, runtime packages)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## File Map
|
||
|
|
|
||
|
|
### New Files — Core Module (`cameleer3-server-core`)
|
||
|
|
|
||
|
|
```
|
||
|
|
src/main/java/com/cameleer3/server/core/runtime/
|
||
|
|
├── Environment.java Record: id, slug, displayName, status, createdAt
|
||
|
|
├── EnvironmentStatus.java Enum: ACTIVE, SUSPENDED
|
||
|
|
├── EnvironmentRepository.java Interface: CRUD + findBySlug
|
||
|
|
├── EnvironmentService.java Business logic: create, list, delete, enforce limits
|
||
|
|
├── App.java Record: id, environmentId, slug, displayName, createdAt
|
||
|
|
├── AppVersion.java Record: id, appId, version, jarPath, sha256, uploadedAt
|
||
|
|
├── AppRepository.java Interface: CRUD + findByEnvironmentId
|
||
|
|
├── AppVersionRepository.java Interface: CRUD + findByAppId
|
||
|
|
├── AppService.java Business logic: create, upload JAR, list, delete
|
||
|
|
├── Deployment.java Record: id, appId, appVersionId, environmentId, status, containerId
|
||
|
|
├── DeploymentStatus.java Enum: STARTING, RUNNING, FAILED, STOPPED
|
||
|
|
├── DeploymentRepository.java Interface: CRUD + findByAppId + findByEnvironmentId
|
||
|
|
├── DeploymentService.java Business logic: deploy, stop, restart, promote
|
||
|
|
├── RuntimeOrchestrator.java Interface: startContainer, stopContainer, getStatus, getLogs
|
||
|
|
├── RuntimeConfig.java Record: jarStoragePath, baseImage, dockerNetwork, routing, etc.
|
||
|
|
├── ContainerRequest.java Record: containerName, jarPath, envVars, memoryLimit, cpuShares
|
||
|
|
├── ContainerStatus.java Record: state, running, exitCode, error
|
||
|
|
└── RoutingMode.java Enum: path, subdomain
|
||
|
|
```
|
||
|
|
|
||
|
|
### New Files — App Module (`cameleer3-server-app`)
|
||
|
|
|
||
|
|
```
|
||
|
|
src/main/java/com/cameleer3/server/app/runtime/
|
||
|
|
├── DockerRuntimeOrchestrator.java Docker implementation using docker-java
|
||
|
|
├── DisabledRuntimeOrchestrator.java No-op implementation (observability-only mode)
|
||
|
|
├── RuntimeOrchestratorAutoConfig.java @Configuration: auto-detects Docker vs K8s vs disabled
|
||
|
|
├── DeploymentExecutor.java @Service: async deployment pipeline
|
||
|
|
├── JarStorageService.java File-system JAR storage with versioning
|
||
|
|
└── ContainerLogCollector.java Collects Docker container stdout/stderr
|
||
|
|
|
||
|
|
src/main/java/com/cameleer3/server/app/storage/
|
||
|
|
├── PostgresEnvironmentRepository.java
|
||
|
|
├── PostgresAppRepository.java
|
||
|
|
├── PostgresAppVersionRepository.java
|
||
|
|
└── PostgresDeploymentRepository.java
|
||
|
|
|
||
|
|
src/main/java/com/cameleer3/server/app/controller/
|
||
|
|
├── EnvironmentAdminController.java CRUD endpoints under /api/v1/admin/environments
|
||
|
|
├── AppController.java App + version CRUD + JAR upload
|
||
|
|
└── DeploymentController.java Deploy, stop, restart, promote, logs
|
||
|
|
|
||
|
|
src/main/resources/db/migration/
|
||
|
|
└── V3__runtime_management.sql Environments, apps, app_versions, deployments tables
|
||
|
|
```
|
||
|
|
|
||
|
|
### Modified Files
|
||
|
|
- `pom.xml` (parent) — add docker-java dependency
|
||
|
|
- `cameleer3-server-app/pom.xml` — add docker-java dependency
|
||
|
|
- `application.yml` — add runtime config properties
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 1: Add docker-java Dependency
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `cameleer3-server-app/pom.xml`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Add docker-java dependency**
|
||
|
|
|
||
|
|
```xml
|
||
|
|
<dependency>
|
||
|
|
<groupId>com.github.docker-java</groupId>
|
||
|
|
<artifactId>docker-java-core</artifactId>
|
||
|
|
<version>3.4.1</version>
|
||
|
|
</dependency>
|
||
|
|
<dependency>
|
||
|
|
<groupId>com.github.docker-java</groupId>
|
||
|
|
<artifactId>docker-java-transport-zerodep</artifactId>
|
||
|
|
<version>3.4.1</version>
|
||
|
|
</dependency>
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Verify build**
|
||
|
|
|
||
|
|
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn compile -pl cameleer3-server-app`
|
||
|
|
Expected: BUILD SUCCESS.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add cameleer3-server-app/pom.xml
|
||
|
|
git commit -m "chore: add docker-java dependency for runtime orchestration"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 2: Database Migration — Runtime Management Tables
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `cameleer3-server-app/src/main/resources/db/migration/V3__runtime_management.sql`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write migration**
|
||
|
|
|
||
|
|
```sql
|
||
|
|
-- V3__runtime_management.sql
|
||
|
|
-- Runtime management: environments, apps, app versions, deployments
|
||
|
|
|
||
|
|
CREATE TABLE environments (
|
||
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
|
|
slug VARCHAR(100) NOT NULL UNIQUE,
|
||
|
|
display_name VARCHAR(255) NOT NULL,
|
||
|
|
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE TABLE apps (
|
||
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
|
|
environment_id UUID NOT NULL REFERENCES environments(id) ON DELETE CASCADE,
|
||
|
|
slug VARCHAR(100) NOT NULL,
|
||
|
|
display_name VARCHAR(255) NOT NULL,
|
||
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
|
|
UNIQUE(environment_id, slug)
|
||
|
|
);
|
||
|
|
CREATE INDEX idx_apps_environment_id ON apps(environment_id);
|
||
|
|
|
||
|
|
CREATE TABLE app_versions (
|
||
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
|
|
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||
|
|
version INTEGER NOT NULL,
|
||
|
|
jar_path VARCHAR(500) NOT NULL,
|
||
|
|
jar_checksum VARCHAR(64) NOT NULL,
|
||
|
|
jar_filename VARCHAR(255),
|
||
|
|
jar_size_bytes BIGINT,
|
||
|
|
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
|
|
UNIQUE(app_id, version)
|
||
|
|
);
|
||
|
|
CREATE INDEX idx_app_versions_app_id ON app_versions(app_id);
|
||
|
|
|
||
|
|
CREATE TABLE deployments (
|
||
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
|
|
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||
|
|
app_version_id UUID NOT NULL REFERENCES app_versions(id),
|
||
|
|
environment_id UUID NOT NULL REFERENCES environments(id),
|
||
|
|
status VARCHAR(20) NOT NULL DEFAULT 'STARTING',
|
||
|
|
container_id VARCHAR(100),
|
||
|
|
container_name VARCHAR(255),
|
||
|
|
error_message TEXT,
|
||
|
|
deployed_at TIMESTAMPTZ,
|
||
|
|
stopped_at TIMESTAMPTZ,
|
||
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
|
|
);
|
||
|
|
CREATE INDEX idx_deployments_app_id ON deployments(app_id);
|
||
|
|
CREATE INDEX idx_deployments_env_id ON deployments(environment_id);
|
||
|
|
|
||
|
|
-- Default environment (standalone mode always has at least one)
|
||
|
|
INSERT INTO environments (slug, display_name) VALUES ('default', 'Default');
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add cameleer3-server-app/src/main/resources/db/migration/V3__runtime_management.sql
|
||
|
|
git commit -m "feat: add runtime management database schema (environments, apps, versions, deployments)"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 3: Core Domain — Environment, App, AppVersion, Deployment Records
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create all records in `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create all domain records**
|
||
|
|
|
||
|
|
```java
|
||
|
|
// Environment.java
|
||
|
|
package com.cameleer3.server.core.runtime;
|
||
|
|
import java.time.Instant;
|
||
|
|
import java.util.UUID;
|
||
|
|
public record Environment(UUID id, String slug, String displayName, EnvironmentStatus status, Instant createdAt) {}
|
||
|
|
|
||
|
|
// EnvironmentStatus.java
|
||
|
|
package com.cameleer3.server.core.runtime;
|
||
|
|
public enum EnvironmentStatus { ACTIVE, SUSPENDED }
|
||
|
|
|
||
|
|
// App.java
|
||
|
|
package com.cameleer3.server.core.runtime;
|
||
|
|
import java.time.Instant;
|
||
|
|
import java.util.UUID;
|
||
|
|
public record App(UUID id, UUID environmentId, String slug, String displayName, Instant createdAt) {}
|
||
|
|
|
||
|
|
// AppVersion.java
|
||
|
|
package com.cameleer3.server.core.runtime;
|
||
|
|
import java.time.Instant;
|
||
|
|
import java.util.UUID;
|
||
|
|
public record AppVersion(UUID id, UUID appId, int version, String jarPath, String jarChecksum,
|
||
|
|
String jarFilename, Long jarSizeBytes, Instant uploadedAt) {}
|
||
|
|
|
||
|
|
// Deployment.java
|
||
|
|
package com.cameleer3.server.core.runtime;
|
||
|
|
import java.time.Instant;
|
||
|
|
import java.util.UUID;
|
||
|
|
public record Deployment(UUID id, UUID appId, UUID appVersionId, UUID environmentId,
|
||
|
|
DeploymentStatus status, String containerId, String containerName,
|
||
|
|
String errorMessage, Instant deployedAt, Instant stoppedAt, Instant createdAt) {
|
||
|
|
public Deployment withStatus(DeploymentStatus newStatus) {
|
||
|
|
return new Deployment(id, appId, appVersionId, environmentId, newStatus,
|
||
|
|
containerId, containerName, errorMessage, deployedAt, stoppedAt, createdAt);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// DeploymentStatus.java
|
||
|
|
package com.cameleer3.server.core.runtime;
|
||
|
|
public enum DeploymentStatus { STARTING, RUNNING, FAILED, STOPPED }
|
||
|
|
|
||
|
|
// RoutingMode.java
|
||
|
|
package com.cameleer3.server.core.runtime;
|
||
|
|
public enum RoutingMode { path, subdomain }
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/
|
||
|
|
git commit -m "feat: add runtime management domain records"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 4: Core — Repository Interfaces and RuntimeOrchestrator
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create repository interfaces and RuntimeOrchestrator in `core/runtime/`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create repository interfaces**
|
||
|
|
|
||
|
|
```java
|
||
|
|
// EnvironmentRepository.java
|
||
|
|
package com.cameleer3.server.core.runtime;
|
||
|
|
import java.util.*;
|
||
|
|
public interface EnvironmentRepository {
|
||
|
|
List<Environment> findAll();
|
||
|
|
Optional<Environment> findById(UUID id);
|
||
|
|
Optional<Environment> findBySlug(String slug);
|
||
|
|
UUID create(String slug, String displayName);
|
||
|
|
void updateDisplayName(UUID id, String displayName);
|
||
|
|
void updateStatus(UUID id, EnvironmentStatus status);
|
||
|
|
void delete(UUID id);
|
||
|
|
}
|
||
|
|
|
||
|
|
// AppRepository.java
|
||
|
|
package com.cameleer3.server.core.runtime;
|
||
|
|
import java.util.*;
|
||
|
|
public interface AppRepository {
|
||
|
|
List<App> findByEnvironmentId(UUID environmentId);
|
||
|
|
Optional<App> findById(UUID id);
|
||
|
|
Optional<App> findByEnvironmentIdAndSlug(UUID environmentId, String slug);
|
||
|
|
UUID create(UUID environmentId, String slug, String displayName);
|
||
|
|
void delete(UUID id);
|
||
|
|
}
|
||
|
|
|
||
|
|
// AppVersionRepository.java
|
||
|
|
package com.cameleer3.server.core.runtime;
|
||
|
|
import java.util.*;
|
||
|
|
public interface AppVersionRepository {
|
||
|
|
List<AppVersion> findByAppId(UUID appId);
|
||
|
|
Optional<AppVersion> findById(UUID id);
|
||
|
|
int findMaxVersion(UUID appId);
|
||
|
|
UUID create(UUID appId, int version, String jarPath, String jarChecksum, String jarFilename, Long jarSizeBytes);
|
||
|
|
}
|
||
|
|
|
||
|
|
// DeploymentRepository.java
|
||
|
|
package com.cameleer3.server.core.runtime;
|
||
|
|
import java.util.*;
|
||
|
|
public interface DeploymentRepository {
|
||
|
|
List<Deployment> findByAppId(UUID appId);
|
||
|
|
List<Deployment> findByEnvironmentId(UUID environmentId);
|
||
|
|
Optional<Deployment> findById(UUID id);
|
||
|
|
Optional<Deployment> findActiveByAppIdAndEnvironmentId(UUID appId, UUID environmentId);
|
||
|
|
UUID create(UUID appId, UUID appVersionId, UUID environmentId, String containerName);
|
||
|
|
void updateStatus(UUID id, DeploymentStatus status, String containerId, String errorMessage);
|
||
|
|
void markDeployed(UUID id);
|
||
|
|
void markStopped(UUID id);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Create RuntimeOrchestrator interface**
|
||
|
|
|
||
|
|
```java
|
||
|
|
// RuntimeOrchestrator.java
|
||
|
|
package com.cameleer3.server.core.runtime;
|
||
|
|
|
||
|
|
import java.util.stream.Stream;
|
||
|
|
|
||
|
|
public interface RuntimeOrchestrator {
|
||
|
|
boolean isEnabled();
|
||
|
|
String startContainer(ContainerRequest request);
|
||
|
|
void stopContainer(String containerId);
|
||
|
|
void removeContainer(String containerId);
|
||
|
|
ContainerStatus getContainerStatus(String containerId);
|
||
|
|
Stream<String> getLogs(String containerId, int tailLines);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ContainerRequest.java
|
||
|
|
package com.cameleer3.server.core.runtime;
|
||
|
|
import java.util.Map;
|
||
|
|
public record ContainerRequest(
|
||
|
|
String containerName,
|
||
|
|
String baseImage,
|
||
|
|
String jarPath,
|
||
|
|
String network,
|
||
|
|
Map<String, String> envVars,
|
||
|
|
Map<String, String> labels,
|
||
|
|
long memoryLimitBytes,
|
||
|
|
int cpuShares,
|
||
|
|
int healthCheckPort
|
||
|
|
) {}
|
||
|
|
|
||
|
|
// ContainerStatus.java
|
||
|
|
package com.cameleer3.server.core.runtime;
|
||
|
|
public record ContainerStatus(String state, boolean running, int exitCode, String error) {
|
||
|
|
public static ContainerStatus notFound() {
|
||
|
|
return new ContainerStatus("not_found", false, -1, "Container not found");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/
|
||
|
|
git commit -m "feat: add runtime repository interfaces and RuntimeOrchestrator"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 5: Core — EnvironmentService, AppService, DeploymentService
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create service classes in `core/runtime/`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create EnvironmentService**
|
||
|
|
|
||
|
|
```java
|
||
|
|
package com.cameleer3.server.core.runtime;
|
||
|
|
|
||
|
|
import java.util.List;
|
||
|
|
import java.util.UUID;
|
||
|
|
|
||
|
|
public class EnvironmentService {
|
||
|
|
private final EnvironmentRepository repo;
|
||
|
|
|
||
|
|
public EnvironmentService(EnvironmentRepository repo) {
|
||
|
|
this.repo = repo;
|
||
|
|
}
|
||
|
|
|
||
|
|
public List<Environment> listAll() { return repo.findAll(); }
|
||
|
|
public Environment getById(UUID id) { return repo.findById(id).orElseThrow(() -> new IllegalArgumentException("Environment not found: " + id)); }
|
||
|
|
public Environment getBySlug(String slug) { return repo.findBySlug(slug).orElseThrow(() -> new IllegalArgumentException("Environment not found: " + slug)); }
|
||
|
|
|
||
|
|
public UUID create(String slug, String displayName) {
|
||
|
|
if (repo.findBySlug(slug).isPresent()) {
|
||
|
|
throw new IllegalArgumentException("Environment with slug '" + slug + "' already exists");
|
||
|
|
}
|
||
|
|
return repo.create(slug, displayName);
|
||
|
|
}
|
||
|
|
|
||
|
|
public void delete(UUID id) {
|
||
|
|
Environment env = getById(id);
|
||
|
|
if ("default".equals(env.slug())) {
|
||
|
|
throw new IllegalArgumentException("Cannot delete the default environment");
|
||
|
|
}
|
||
|
|
repo.delete(id);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Create AppService**
|
||
|
|
|
||
|
|
```java
|
||
|
|
package com.cameleer3.server.core.runtime;
|
||
|
|
|
||
|
|
import org.slf4j.Logger;
|
||
|
|
import org.slf4j.LoggerFactory;
|
||
|
|
|
||
|
|
import java.io.IOException;
|
||
|
|
import java.io.InputStream;
|
||
|
|
import java.nio.file.*;
|
||
|
|
import java.security.MessageDigest;
|
||
|
|
import java.util.HexFormat;
|
||
|
|
import java.util.List;
|
||
|
|
import java.util.UUID;
|
||
|
|
|
||
|
|
public class AppService {
|
||
|
|
private static final Logger log = LoggerFactory.getLogger(AppService.class);
|
||
|
|
|
||
|
|
private final AppRepository appRepo;
|
||
|
|
private final AppVersionRepository versionRepo;
|
||
|
|
private final String jarStoragePath;
|
||
|
|
|
||
|
|
public AppService(AppRepository appRepo, AppVersionRepository versionRepo, String jarStoragePath) {
|
||
|
|
this.appRepo = appRepo;
|
||
|
|
this.versionRepo = versionRepo;
|
||
|
|
this.jarStoragePath = jarStoragePath;
|
||
|
|
}
|
||
|
|
|
||
|
|
public List<App> listByEnvironment(UUID environmentId) { return appRepo.findByEnvironmentId(environmentId); }
|
||
|
|
public App getById(UUID id) { return appRepo.findById(id).orElseThrow(() -> new IllegalArgumentException("App not found: " + id)); }
|
||
|
|
public List<AppVersion> listVersions(UUID appId) { return versionRepo.findByAppId(appId); }
|
||
|
|
|
||
|
|
public UUID createApp(UUID environmentId, String slug, String displayName) {
|
||
|
|
if (appRepo.findByEnvironmentIdAndSlug(environmentId, slug).isPresent()) {
|
||
|
|
throw new IllegalArgumentException("App with slug '" + slug + "' already exists in this environment");
|
||
|
|
}
|
||
|
|
return appRepo.create(environmentId, slug, displayName);
|
||
|
|
}
|
||
|
|
|
||
|
|
public AppVersion uploadJar(UUID appId, String filename, InputStream jarData, long size) throws IOException {
|
||
|
|
App app = getById(appId);
|
||
|
|
int nextVersion = versionRepo.findMaxVersion(appId) + 1;
|
||
|
|
|
||
|
|
// Store JAR: {jarStoragePath}/{appId}/v{version}/app.jar
|
||
|
|
Path versionDir = Path.of(jarStoragePath, appId.toString(), "v" + nextVersion);
|
||
|
|
Files.createDirectories(versionDir);
|
||
|
|
Path jarFile = versionDir.resolve("app.jar");
|
||
|
|
|
||
|
|
MessageDigest digest;
|
||
|
|
try { digest = MessageDigest.getInstance("SHA-256"); }
|
||
|
|
catch (Exception e) { throw new RuntimeException(e); }
|
||
|
|
|
||
|
|
try (InputStream in = jarData) {
|
||
|
|
byte[] buffer = new byte[8192];
|
||
|
|
int bytesRead;
|
||
|
|
try (var out = Files.newOutputStream(jarFile)) {
|
||
|
|
while ((bytesRead = in.read(buffer)) != -1) {
|
||
|
|
out.write(buffer, 0, bytesRead);
|
||
|
|
digest.update(buffer, 0, bytesRead);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
String checksum = HexFormat.of().formatHex(digest.digest());
|
||
|
|
UUID versionId = versionRepo.create(appId, nextVersion, jarFile.toString(), checksum, filename, size);
|
||
|
|
|
||
|
|
log.info("Uploaded JAR for app {}: version={}, size={}, sha256={}", appId, nextVersion, size, checksum);
|
||
|
|
return versionRepo.findById(versionId).orElseThrow();
|
||
|
|
}
|
||
|
|
|
||
|
|
public String resolveJarPath(UUID appVersionId) {
|
||
|
|
AppVersion version = versionRepo.findById(appVersionId)
|
||
|
|
.orElseThrow(() -> new IllegalArgumentException("AppVersion not found: " + appVersionId));
|
||
|
|
return version.jarPath();
|
||
|
|
}
|
||
|
|
|
||
|
|
public void deleteApp(UUID id) {
|
||
|
|
appRepo.delete(id);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Create DeploymentService**
|
||
|
|
|
||
|
|
```java
|
||
|
|
package com.cameleer3.server.core.runtime;
|
||
|
|
|
||
|
|
import org.slf4j.Logger;
|
||
|
|
import org.slf4j.LoggerFactory;
|
||
|
|
|
||
|
|
import java.util.List;
|
||
|
|
import java.util.UUID;
|
||
|
|
|
||
|
|
public class DeploymentService {
|
||
|
|
private static final Logger log = LoggerFactory.getLogger(DeploymentService.class);
|
||
|
|
|
||
|
|
private final DeploymentRepository deployRepo;
|
||
|
|
private final AppService appService;
|
||
|
|
private final EnvironmentService envService;
|
||
|
|
|
||
|
|
public DeploymentService(DeploymentRepository deployRepo, AppService appService, EnvironmentService envService) {
|
||
|
|
this.deployRepo = deployRepo;
|
||
|
|
this.appService = appService;
|
||
|
|
this.envService = envService;
|
||
|
|
}
|
||
|
|
|
||
|
|
public List<Deployment> listByApp(UUID appId) { return deployRepo.findByAppId(appId); }
|
||
|
|
public Deployment getById(UUID id) { return deployRepo.findById(id).orElseThrow(() -> new IllegalArgumentException("Deployment not found: " + id)); }
|
||
|
|
|
||
|
|
/** Create a deployment record. Actual container start is handled by DeploymentExecutor (async). */
|
||
|
|
public Deployment createDeployment(UUID appId, UUID appVersionId, UUID environmentId) {
|
||
|
|
App app = appService.getById(appId);
|
||
|
|
Environment env = envService.getById(environmentId);
|
||
|
|
String containerName = env.slug() + "-" + app.slug();
|
||
|
|
|
||
|
|
UUID deploymentId = deployRepo.create(appId, appVersionId, environmentId, containerName);
|
||
|
|
return deployRepo.findById(deploymentId).orElseThrow();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Promote: deploy the same app version to a different environment. */
|
||
|
|
public Deployment promote(UUID appId, UUID appVersionId, UUID targetEnvironmentId) {
|
||
|
|
return createDeployment(appId, appVersionId, targetEnvironmentId);
|
||
|
|
}
|
||
|
|
|
||
|
|
public void markRunning(UUID deploymentId, String containerId) {
|
||
|
|
deployRepo.updateStatus(deploymentId, DeploymentStatus.RUNNING, containerId, null);
|
||
|
|
deployRepo.markDeployed(deploymentId);
|
||
|
|
}
|
||
|
|
|
||
|
|
public void markFailed(UUID deploymentId, String errorMessage) {
|
||
|
|
deployRepo.updateStatus(deploymentId, DeploymentStatus.FAILED, null, errorMessage);
|
||
|
|
}
|
||
|
|
|
||
|
|
public void markStopped(UUID deploymentId) {
|
||
|
|
deployRepo.updateStatus(deploymentId, DeploymentStatus.STOPPED, null, null);
|
||
|
|
deployRepo.markStopped(deploymentId);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/
|
||
|
|
git commit -m "feat: add EnvironmentService, AppService, DeploymentService"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 6: App Module — PostgreSQL Repositories
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create all Postgres repositories in `app/storage/`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Implement all four repositories**
|
||
|
|
|
||
|
|
Follow the pattern from `PostgresUserRepository.java` — `JdbcTemplate` with row mappers. Each repository implements its core interface with standard SQL (INSERT, SELECT, UPDATE, DELETE).
|
||
|
|
|
||
|
|
Key patterns to follow:
|
||
|
|
- Constructor injection of `JdbcTemplate`
|
||
|
|
- RowMapper lambdas returning records
|
||
|
|
- `UUID.randomUUID()` for ID generation
|
||
|
|
- `Timestamp.from(Instant)` for timestamp parameters
|
||
|
|
|
||
|
|
- [ ] **Step 2: Wire beans**
|
||
|
|
|
||
|
|
Create `RuntimeBeanConfig.java` in `app/config/`:
|
||
|
|
|
||
|
|
```java
|
||
|
|
@Configuration
|
||
|
|
public class RuntimeBeanConfig {
|
||
|
|
@Bean
|
||
|
|
public EnvironmentRepository environmentRepository(JdbcTemplate jdbc) {
|
||
|
|
return new PostgresEnvironmentRepository(jdbc);
|
||
|
|
}
|
||
|
|
@Bean
|
||
|
|
public AppRepository appRepository(JdbcTemplate jdbc) {
|
||
|
|
return new PostgresAppRepository(jdbc);
|
||
|
|
}
|
||
|
|
@Bean
|
||
|
|
public AppVersionRepository appVersionRepository(JdbcTemplate jdbc) {
|
||
|
|
return new PostgresAppVersionRepository(jdbc);
|
||
|
|
}
|
||
|
|
@Bean
|
||
|
|
public DeploymentRepository deploymentRepository(JdbcTemplate jdbc) {
|
||
|
|
return new PostgresDeploymentRepository(jdbc);
|
||
|
|
}
|
||
|
|
@Bean
|
||
|
|
public EnvironmentService environmentService(EnvironmentRepository repo) {
|
||
|
|
return new EnvironmentService(repo);
|
||
|
|
}
|
||
|
|
@Bean
|
||
|
|
public AppService appService(AppRepository appRepo, AppVersionRepository versionRepo,
|
||
|
|
@Value("${cameleer.runtime.jar-storage-path:/data/jars}") String jarStoragePath) {
|
||
|
|
return new AppService(appRepo, versionRepo, jarStoragePath);
|
||
|
|
}
|
||
|
|
@Bean
|
||
|
|
public DeploymentService deploymentService(DeploymentRepository deployRepo, AppService appService, EnvironmentService envService) {
|
||
|
|
return new DeploymentService(deployRepo, appService, envService);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Run tests**
|
||
|
|
|
||
|
|
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app`
|
||
|
|
Expected: PASS (Flyway applies V3 migration, context loads).
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/Postgres*Repository.java
|
||
|
|
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/RuntimeBeanConfig.java
|
||
|
|
git commit -m "feat: implement PostgreSQL repositories for runtime management"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 7: Docker Runtime Orchestrator
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DockerRuntimeOrchestrator.java`
|
||
|
|
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DisabledRuntimeOrchestrator.java`
|
||
|
|
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/RuntimeOrchestratorAutoConfig.java`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Implement DisabledRuntimeOrchestrator**
|
||
|
|
|
||
|
|
```java
|
||
|
|
package com.cameleer3.server.app.runtime;
|
||
|
|
|
||
|
|
import com.cameleer3.server.core.runtime.*;
|
||
|
|
import java.util.stream.Stream;
|
||
|
|
|
||
|
|
public class DisabledRuntimeOrchestrator implements RuntimeOrchestrator {
|
||
|
|
@Override public boolean isEnabled() { return false; }
|
||
|
|
@Override public String startContainer(ContainerRequest r) { throw new UnsupportedOperationException("Runtime management disabled"); }
|
||
|
|
@Override public void stopContainer(String id) { throw new UnsupportedOperationException("Runtime management disabled"); }
|
||
|
|
@Override public void removeContainer(String id) { throw new UnsupportedOperationException("Runtime management disabled"); }
|
||
|
|
@Override public ContainerStatus getContainerStatus(String id) { return ContainerStatus.notFound(); }
|
||
|
|
@Override public Stream<String> getLogs(String id, int tail) { return Stream.empty(); }
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Implement DockerRuntimeOrchestrator**
|
||
|
|
|
||
|
|
Port from SaaS `DockerRuntimeOrchestrator.java`, adapted:
|
||
|
|
- Uses docker-java `DockerClientImpl` with zerodep transport
|
||
|
|
- `startContainer()`: creates container from base image with volume mount for JAR (instead of image build), sets env vars, Traefik labels, health check, resource limits
|
||
|
|
- `stopContainer()`: stops with 30s timeout
|
||
|
|
- `removeContainer()`: force remove
|
||
|
|
- `getContainerStatus()`: inspect container state
|
||
|
|
- `getLogs()`: tail container logs
|
||
|
|
|
||
|
|
Key difference from SaaS version: **no image build**. The base image is pre-built. JAR is volume-mounted:
|
||
|
|
|
||
|
|
```java
|
||
|
|
@Override
|
||
|
|
public String startContainer(ContainerRequest request) {
|
||
|
|
List<String> envList = request.envVars().entrySet().stream()
|
||
|
|
.map(e -> e.getKey() + "=" + e.getValue()).toList();
|
||
|
|
|
||
|
|
// Volume bind: mount JAR into container
|
||
|
|
Bind jarBind = new Bind(request.jarPath(), new Volume("/app/app.jar"), AccessMode.ro);
|
||
|
|
|
||
|
|
HostConfig hostConfig = HostConfig.newHostConfig()
|
||
|
|
.withMemory(request.memoryLimitBytes())
|
||
|
|
.withMemorySwap(request.memoryLimitBytes())
|
||
|
|
.withCpuShares(request.cpuShares())
|
||
|
|
.withNetworkMode(request.network())
|
||
|
|
.withBinds(jarBind);
|
||
|
|
|
||
|
|
CreateContainerResponse container = dockerClient.createContainerCmd(request.baseImage())
|
||
|
|
.withName(request.containerName())
|
||
|
|
.withEnv(envList)
|
||
|
|
.withLabels(request.labels())
|
||
|
|
.withHostConfig(hostConfig)
|
||
|
|
.withHealthcheck(new HealthCheck()
|
||
|
|
.withTest(List.of("CMD-SHELL", "wget -qO- http://localhost:" + request.healthCheckPort() + "/cameleer/health || exit 1"))
|
||
|
|
.withInterval(10_000_000_000L)
|
||
|
|
.withTimeout(5_000_000_000L)
|
||
|
|
.withRetries(3)
|
||
|
|
.withStartPeriod(30_000_000_000L))
|
||
|
|
.exec();
|
||
|
|
|
||
|
|
dockerClient.startContainerCmd(container.getId()).exec();
|
||
|
|
return container.getId();
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Implement RuntimeOrchestratorAutoConfig**
|
||
|
|
|
||
|
|
```java
|
||
|
|
package com.cameleer3.server.app.runtime;
|
||
|
|
|
||
|
|
import com.cameleer3.server.core.runtime.RuntimeOrchestrator;
|
||
|
|
import org.slf4j.Logger;
|
||
|
|
import org.slf4j.LoggerFactory;
|
||
|
|
import org.springframework.context.annotation.Bean;
|
||
|
|
import org.springframework.context.annotation.Configuration;
|
||
|
|
|
||
|
|
import java.nio.file.Files;
|
||
|
|
import java.nio.file.Path;
|
||
|
|
|
||
|
|
@Configuration
|
||
|
|
public class RuntimeOrchestratorAutoConfig {
|
||
|
|
|
||
|
|
private static final Logger log = LoggerFactory.getLogger(RuntimeOrchestratorAutoConfig.class);
|
||
|
|
|
||
|
|
@Bean
|
||
|
|
public RuntimeOrchestrator runtimeOrchestrator() {
|
||
|
|
// Auto-detect: Docker socket available?
|
||
|
|
if (Files.exists(Path.of("/var/run/docker.sock"))) {
|
||
|
|
log.info("Docker socket detected — enabling Docker runtime orchestrator");
|
||
|
|
return new DockerRuntimeOrchestrator();
|
||
|
|
}
|
||
|
|
// TODO: K8s detection (check for service account token)
|
||
|
|
log.info("No Docker socket or K8s detected — runtime management disabled (observability-only mode)");
|
||
|
|
return new DisabledRuntimeOrchestrator();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/
|
||
|
|
git commit -m "feat: implement DockerRuntimeOrchestrator with volume-mount JAR deployment"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 8: DeploymentExecutor — Async Deployment Pipeline
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Implement async deployment pipeline**
|
||
|
|
|
||
|
|
```java
|
||
|
|
package com.cameleer3.server.app.runtime;
|
||
|
|
|
||
|
|
import com.cameleer3.server.core.runtime.*;
|
||
|
|
import org.slf4j.Logger;
|
||
|
|
import org.slf4j.LoggerFactory;
|
||
|
|
import org.springframework.scheduling.annotation.Async;
|
||
|
|
import org.springframework.stereotype.Service;
|
||
|
|
|
||
|
|
import java.util.HashMap;
|
||
|
|
import java.util.Map;
|
||
|
|
|
||
|
|
@Service
|
||
|
|
public class DeploymentExecutor {
|
||
|
|
|
||
|
|
private static final Logger log = LoggerFactory.getLogger(DeploymentExecutor.class);
|
||
|
|
|
||
|
|
private final RuntimeOrchestrator orchestrator;
|
||
|
|
private final DeploymentService deploymentService;
|
||
|
|
private final AppService appService;
|
||
|
|
private final EnvironmentService envService;
|
||
|
|
// Inject runtime config values
|
||
|
|
|
||
|
|
public DeploymentExecutor(RuntimeOrchestrator orchestrator, DeploymentService deploymentService,
|
||
|
|
AppService appService, EnvironmentService envService) {
|
||
|
|
this.orchestrator = orchestrator;
|
||
|
|
this.deploymentService = deploymentService;
|
||
|
|
this.appService = appService;
|
||
|
|
this.envService = envService;
|
||
|
|
}
|
||
|
|
|
||
|
|
@Async("deploymentExecutor")
|
||
|
|
public void executeAsync(Deployment deployment) {
|
||
|
|
try {
|
||
|
|
// Stop existing deployment in same environment for same app
|
||
|
|
// ... (find active deployment, stop container)
|
||
|
|
|
||
|
|
String jarPath = appService.resolveJarPath(deployment.appVersionId());
|
||
|
|
App app = appService.getById(deployment.appId());
|
||
|
|
Environment env = envService.getById(deployment.environmentId());
|
||
|
|
|
||
|
|
Map<String, String> envVars = new HashMap<>();
|
||
|
|
envVars.put("CAMELEER_EXPORT_TYPE", "HTTP");
|
||
|
|
envVars.put("CAMELEER_EXPORT_ENDPOINT", /* server endpoint */);
|
||
|
|
envVars.put("CAMELEER_AUTH_TOKEN", /* bootstrap token */);
|
||
|
|
envVars.put("CAMELEER_APPLICATION_ID", app.slug());
|
||
|
|
envVars.put("CAMELEER_ENVIRONMENT_ID", env.slug());
|
||
|
|
envVars.put("CAMELEER_DISPLAY_NAME", deployment.containerName());
|
||
|
|
|
||
|
|
Map<String, String> labels = buildTraefikLabels(app, env, deployment);
|
||
|
|
|
||
|
|
ContainerRequest request = new ContainerRequest(
|
||
|
|
deployment.containerName(),
|
||
|
|
/* baseImage */, jarPath, /* network */,
|
||
|
|
envVars, labels, /* memoryLimit */, /* cpuShares */, 9464);
|
||
|
|
|
||
|
|
String containerId = orchestrator.startContainer(request);
|
||
|
|
waitForHealthy(containerId, 60);
|
||
|
|
|
||
|
|
deploymentService.markRunning(deployment.id(), containerId);
|
||
|
|
log.info("Deployment {} is RUNNING (container={})", deployment.id(), containerId);
|
||
|
|
|
||
|
|
} catch (Exception e) {
|
||
|
|
log.error("Deployment {} FAILED: {}", deployment.id(), e.getMessage(), e);
|
||
|
|
deploymentService.markFailed(deployment.id(), e.getMessage());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private void waitForHealthy(String containerId, int timeoutSeconds) throws InterruptedException {
|
||
|
|
long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L;
|
||
|
|
while (System.currentTimeMillis() < deadline) {
|
||
|
|
ContainerStatus status = orchestrator.getContainerStatus(containerId);
|
||
|
|
if ("healthy".equalsIgnoreCase(status.state()) || (status.running() && "running".equalsIgnoreCase(status.state()))) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!status.running()) {
|
||
|
|
throw new RuntimeException("Container stopped unexpectedly: " + status.error());
|
||
|
|
}
|
||
|
|
Thread.sleep(2000);
|
||
|
|
}
|
||
|
|
throw new RuntimeException("Container health check timed out after " + timeoutSeconds + "s");
|
||
|
|
}
|
||
|
|
|
||
|
|
private Map<String, String> buildTraefikLabels(App app, Environment env, Deployment deployment) {
|
||
|
|
// TODO: implement path-based and subdomain-based Traefik labels based on routing config
|
||
|
|
return Map.of("traefik.enable", "true");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Add async config**
|
||
|
|
|
||
|
|
Add to `RuntimeBeanConfig.java` or create `AsyncConfig.java`:
|
||
|
|
|
||
|
|
```java
|
||
|
|
@Bean(name = "deploymentExecutor")
|
||
|
|
public TaskExecutor deploymentTaskExecutor() {
|
||
|
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||
|
|
executor.setCorePoolSize(4);
|
||
|
|
executor.setMaxPoolSize(4);
|
||
|
|
executor.setQueueCapacity(25);
|
||
|
|
executor.setThreadNamePrefix("deploy-");
|
||
|
|
executor.initialize();
|
||
|
|
return executor;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java
|
||
|
|
git commit -m "feat: implement async DeploymentExecutor pipeline"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 9: REST Controllers — Environment, App, Deployment
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `EnvironmentAdminController.java` (under `/api/v1/admin/environments`, ADMIN role)
|
||
|
|
- Create: `AppController.java` (under `/api/v1/apps`, OPERATOR role)
|
||
|
|
- Create: `DeploymentController.java` (under `/api/v1/apps/{appId}/deployments`, OPERATOR role)
|
||
|
|
|
||
|
|
- [ ] **Step 1: Implement EnvironmentAdminController**
|
||
|
|
|
||
|
|
CRUD for environments. Path: `/api/v1/admin/environments`. Requires ADMIN role. Follows existing controller patterns (OpenAPI annotations, ResponseEntity).
|
||
|
|
|
||
|
|
- [ ] **Step 2: Implement AppController**
|
||
|
|
|
||
|
|
App CRUD + JAR upload. Path: `/api/v1/apps`. Requires OPERATOR role. JAR upload via `multipart/form-data`. Returns app versions.
|
||
|
|
|
||
|
|
Key endpoint for JAR upload:
|
||
|
|
```java
|
||
|
|
@PostMapping(value = "/{appId}/versions", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||
|
|
public ResponseEntity<AppVersion> uploadJar(@PathVariable UUID appId,
|
||
|
|
@RequestParam("file") MultipartFile file) throws IOException {
|
||
|
|
AppVersion version = appService.uploadJar(appId, file.getOriginalFilename(), file.getInputStream(), file.getSize());
|
||
|
|
return ResponseEntity.status(201).body(version);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Implement DeploymentController**
|
||
|
|
|
||
|
|
Deploy, stop, restart, promote, logs. Path: `/api/v1/apps/{appId}/deployments`. Requires OPERATOR role.
|
||
|
|
|
||
|
|
Key endpoints:
|
||
|
|
```java
|
||
|
|
@PostMapping
|
||
|
|
public ResponseEntity<Deployment> deploy(@PathVariable UUID appId, @RequestBody DeployRequest request) {
|
||
|
|
// request contains: appVersionId, environmentId
|
||
|
|
Deployment deployment = deploymentService.createDeployment(appId, request.appVersionId(), request.environmentId());
|
||
|
|
deploymentExecutor.executeAsync(deployment);
|
||
|
|
return ResponseEntity.accepted().body(deployment);
|
||
|
|
}
|
||
|
|
|
||
|
|
@PostMapping("/{deploymentId}/promote")
|
||
|
|
public ResponseEntity<Deployment> promote(@PathVariable UUID appId, @PathVariable UUID deploymentId,
|
||
|
|
@RequestBody PromoteRequest request) {
|
||
|
|
Deployment source = deploymentService.getById(deploymentId);
|
||
|
|
Deployment promoted = deploymentService.promote(appId, source.appVersionId(), request.targetEnvironmentId());
|
||
|
|
deploymentExecutor.executeAsync(promoted);
|
||
|
|
return ResponseEntity.accepted().body(promoted);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Add security rules to SecurityConfig**
|
||
|
|
|
||
|
|
Add to `SecurityConfig.filterChain()`:
|
||
|
|
```java
|
||
|
|
// Runtime management (OPERATOR+)
|
||
|
|
.requestMatchers("/api/v1/apps/**").hasAnyRole("OPERATOR", "ADMIN")
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/EnvironmentAdminController.java
|
||
|
|
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AppController.java
|
||
|
|
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DeploymentController.java
|
||
|
|
git commit -m "feat: add REST controllers for environment, app, and deployment management"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 10: Configuration and Application Properties
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `cameleer3-server-app/src/main/resources/application.yml`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Add runtime config properties**
|
||
|
|
|
||
|
|
```yaml
|
||
|
|
cameleer:
|
||
|
|
runtime:
|
||
|
|
enabled: ${CAMELEER_RUNTIME_ENABLED:true}
|
||
|
|
jar-storage-path: ${CAMELEER_JAR_STORAGE_PATH:/data/jars}
|
||
|
|
base-image: ${CAMELEER_RUNTIME_BASE_IMAGE:cameleer-runtime-base:latest}
|
||
|
|
docker-network: ${CAMELEER_DOCKER_NETWORK:cameleer}
|
||
|
|
agent-health-port: 9464
|
||
|
|
health-check-timeout: 60
|
||
|
|
container-memory-limit: ${CAMELEER_CONTAINER_MEMORY_LIMIT:512m}
|
||
|
|
container-cpu-shares: ${CAMELEER_CONTAINER_CPU_SHARES:512}
|
||
|
|
routing-mode: ${CAMELEER_ROUTING_MODE:path}
|
||
|
|
routing-domain: ${CAMELEER_ROUTING_DOMAIN:localhost}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run full test suite**
|
||
|
|
|
||
|
|
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify`
|
||
|
|
Expected: PASS.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add cameleer3-server-app/src/main/resources/application.yml
|
||
|
|
git commit -m "feat: add runtime management configuration properties"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 11: Integration Tests
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write EnvironmentAdminController integration test**
|
||
|
|
|
||
|
|
Test CRUD operations for environments. Follows existing pattern from `AgentRegistrationControllerIT`.
|
||
|
|
|
||
|
|
- [ ] **Step 2: Write AppController integration test**
|
||
|
|
|
||
|
|
Test app creation, JAR upload, version listing.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Write DeploymentController integration test**
|
||
|
|
|
||
|
|
Test deployment creation (with `DisabledRuntimeOrchestrator` — verifies the deployment record is created even if Docker is unavailable). Full Docker tests require Docker-in-Docker and are out of scope for CI.
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/
|
||
|
|
git commit -m "test: add integration tests for runtime management API"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 12: Final Verification
|
||
|
|
|
||
|
|
- [ ] **Step 1: Run full build**
|
||
|
|
|
||
|
|
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify`
|
||
|
|
Expected: All tests PASS.
|
||
|
|
|
||
|
|
- [ ] **Step 2: Verify schema applies cleanly**
|
||
|
|
|
||
|
|
Fresh Testcontainers PostgreSQL should apply V1 + V2 + V3 without errors.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Commit any remaining fixes**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add -A
|
||
|
|
git commit -m "chore: finalize runtime management — all tests passing"
|
||
|
|
```
|