Compare commits
22 Commits
feature/ph
...
feat/phase
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af04f7b4a1 | ||
|
|
abc06f57da | ||
|
|
0bd54f2a95 | ||
|
|
fc34626a88 | ||
|
|
59df59f406 | ||
|
|
23a474fbf3 | ||
|
|
d2ea256cd8 | ||
|
|
51f5822364 | ||
|
|
2151801d40 | ||
|
|
90c1e36cb7 | ||
|
|
731690191b | ||
|
|
36069bae07 | ||
|
|
785bdab3d1 | ||
|
|
34e98ab176 | ||
|
|
8511d10343 | ||
|
|
4cb15c9bea | ||
|
|
bd8dfcf147 | ||
|
|
803b8c9876 | ||
|
|
c0fce36d4a | ||
|
|
fa7853b02d | ||
|
|
0326dc6cce | ||
| 5d14f78b9d |
@@ -23,3 +23,7 @@ CAMELEER_JWT_PUBLIC_KEY_PATH=/etc/cameleer/keys/ed25519.pub
|
||||
|
||||
# Domain (for Traefik TLS)
|
||||
DOMAIN=localhost
|
||||
|
||||
CAMELEER_AUTH_TOKEN=change_me_bootstrap_token
|
||||
CAMELEER_CONTAINER_MEMORY_LIMIT=512m
|
||||
CAMELEER_CONTAINER_CPU_SHARES=512
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
- name: Build and Test (unit tests only)
|
||||
run: >-
|
||||
mvn clean verify -B
|
||||
-Dsurefire.excludes="**/AuthControllerTest.java,**/TenantControllerTest.java,**/LicenseControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java"
|
||||
-Dsurefire.excludes="**/AuthControllerTest.java,**/TenantControllerTest.java,**/LicenseControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java,**/EnvironmentControllerTest.java,**/AppControllerTest.java,**/DeploymentControllerTest.java"
|
||||
|
||||
docker:
|
||||
needs: build
|
||||
|
||||
35
.gitea/workflows/sonarqube.yml
Normal file
35
.gitea/workflows/sonarqube.yml
Normal file
@@ -0,0 +1,35 @@
|
||||
name: SonarQube Analysis
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *' # Nightly at 02:00 UTC
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
jobs:
|
||||
sonarqube:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: gitea.siegeln.net/cameleer/cameleer-build:1
|
||||
credentials:
|
||||
username: cameleer
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Full history for blame data
|
||||
|
||||
- name: Cache Maven dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.m2/repository
|
||||
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
|
||||
restore-keys: ${{ runner.os }}-maven-
|
||||
|
||||
- name: Build, Test and Analyze
|
||||
run: >-
|
||||
mvn clean verify sonar:sonar --batch-mode
|
||||
-Dsurefire.excludes="**/AuthControllerTest.java,**/TenantControllerTest.java,**/LicenseControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java,**/EnvironmentControllerTest.java,**/AppControllerTest.java,**/DeploymentControllerTest.java"
|
||||
-Dsonar.host.url=${{ secrets.SONAR_HOST_URL }}
|
||||
-Dsonar.token=${{ secrets.SONAR_TOKEN }}
|
||||
-Dsonar.projectKey=cameleer-saas
|
||||
-Dsonar.projectName="Cameleer SaaS"
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -18,3 +18,6 @@ Thumbs.db
|
||||
# Environment
|
||||
.env
|
||||
*.env.local
|
||||
|
||||
# Worktrees
|
||||
.worktrees/
|
||||
|
||||
@@ -58,6 +58,7 @@ services:
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./keys:/etc/cameleer/keys:ro
|
||||
- jardata:/data/jars
|
||||
environment:
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-cameleer_saas}
|
||||
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
|
||||
@@ -69,6 +70,9 @@ services:
|
||||
LOGTO_M2M_CLIENT_SECRET: ${LOGTO_M2M_CLIENT_SECRET:-}
|
||||
CAMELEER_JWT_PRIVATE_KEY_PATH: ${CAMELEER_JWT_PRIVATE_KEY_PATH:-}
|
||||
CAMELEER_JWT_PUBLIC_KEY_PATH: ${CAMELEER_JWT_PUBLIC_KEY_PATH:-}
|
||||
CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}
|
||||
CAMELEER3_SERVER_ENDPOINT: http://cameleer3-server:8081
|
||||
CLICKHOUSE_URL: jdbc:clickhouse://clickhouse:8123/cameleer
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.api.rule=PathPrefix(`/api`)
|
||||
@@ -89,6 +93,7 @@ services:
|
||||
environment:
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-cameleer_saas}
|
||||
CLICKHOUSE_URL: jdbc:clickhouse://clickhouse:8123/cameleer
|
||||
CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.observe.rule=PathPrefix(`/observe`)
|
||||
@@ -120,3 +125,4 @@ volumes:
|
||||
pgdata:
|
||||
chdata:
|
||||
acme:
|
||||
jardata:
|
||||
|
||||
19
docker/runtime-base/Dockerfile
Normal file
19
docker/runtime-base/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# Agent JAR is copied during CI build from Gitea Maven registry
|
||||
# ARG AGENT_JAR=cameleer3-agent-1.0-SNAPSHOT-shaded.jar
|
||||
COPY agent.jar /app/agent.jar
|
||||
|
||||
ENTRYPOINT exec java \
|
||||
-Dcameleer.export.type=${CAMELEER_EXPORT_TYPE:-HTTP} \
|
||||
-Dcameleer.export.endpoint=${CAMELEER_EXPORT_ENDPOINT} \
|
||||
-Dcameleer.agent.name=${HOSTNAME} \
|
||||
-Dcameleer.agent.application=${CAMELEER_APPLICATION_ID:-default} \
|
||||
-Dcameleer.agent.environment=${CAMELEER_ENVIRONMENT_ID:-default} \
|
||||
-Dcameleer.routeControl.enabled=${CAMELEER_ROUTE_CONTROL_ENABLED:-false} \
|
||||
-Dcameleer.replay.enabled=${CAMELEER_REPLAY_ENABLED:-false} \
|
||||
-Dcameleer.health.enabled=true \
|
||||
-Dcameleer.health.port=9464 \
|
||||
-javaagent:/app/agent.jar \
|
||||
-jar /app/app.jar
|
||||
3522
docs/superpowers/plans/2026-04-04-phase-3-runtime-orchestration.md
Normal file
3522
docs/superpowers/plans/2026-04-04-phase-3-runtime-orchestration.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,424 @@
|
||||
# Phase 3: Runtime Orchestration + Environments
|
||||
|
||||
**Date:** 2026-04-04
|
||||
**Status:** Draft
|
||||
**Depends on:** Phase 2 (Tenants + Identity + Licensing)
|
||||
**Gitea issue:** #26
|
||||
|
||||
## Context
|
||||
|
||||
Phase 2 delivered multi-tenancy, identity (Logto OIDC), and license management. The platform can create tenants and issue licenses, but there is nothing to run yet. Phase 3 is the core product differentiator: customers upload a Camel JAR, the platform builds an immutable container image with the cameleer3 agent auto-injected, and deploys it to a logical environment. This is "managed Camel runtime" — similar to Coolify or MuleSoft CloudHub, but purpose-built for Apache Camel with deep observability.
|
||||
|
||||
Docker-first. The `KubernetesRuntimeOrchestrator` is deferred to Phase 5.
|
||||
|
||||
**Single-node constraint:** Because Phase 3 builds images locally via Docker socket (no registry push), the cameleer-saas control plane and the Docker daemon must reside on the same host. This is inherent to the single-tenant Docker Compose stack and is acceptable for that target. In K8s mode (Phase 5), images are built via Kaniko and pushed to a registry, removing this constraint.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| JAR delivery | Direct HTTP upload (multipart) | Simplest path. Git-based and image-ref options can be added later. |
|
||||
| Agent JAR source | Bundled in `cameleer-runtime-base` image | Version-locked to platform release. Updated by rebuilding the platform image with the new agent version. No runtime network dependency. |
|
||||
| Build speed | Pre-built base image + single-layer customer add | Customer image build is `FROM base` + `COPY app.jar`. ~1-3 seconds. |
|
||||
| Deployment model | Async with polling | Image builds are inherently slow. Deploy returns immediately with deployment ID. Client polls for status. |
|
||||
| Entity hierarchy | Environment → App → Deployment | User thinks "I'm in dev, deploy my app." Environment is the workspace context. |
|
||||
| Environment provisioning | Hybrid auto + manual | Every tenant gets a `default` environment on creation. Additional environments created manually, tier limit enforced. |
|
||||
| Cross-environment isolation | Logical (not network) | Docker single-tenant mode — customer owns the stack. Data separated by `environmentId` in cameleer3-server. Network isolation is a K8s Phase 5 concern. |
|
||||
| Container networking | Shared `cameleer` bridge network | Customer containers join the existing network. Agent reaches cameleer3-server at `http://cameleer3-server:8081`. |
|
||||
| Container naming | `{tenant-slug}-{env-slug}-{app-slug}` | Human-readable, unique, identifies tenant+environment+app at a glance. |
|
||||
| Bootstrap tokens | Shared `CAMELEER_AUTH_TOKEN` from cameleer3-server config | Platform reads the existing token and injects it into customer containers. Environment separation via agent `environmentId` claim, not token. Per-environment tokens deferred to K8s Phase 5. |
|
||||
| Health checking | Agent health endpoint (port 9464) | Guaranteed to exist, no user config needed. User-defined health endpoints deferred. |
|
||||
| Inbound HTTP routing | Not in Phase 3 | Most Camel apps are consumers (queues, polls), not servers. Traefik routing for customer apps deferred to Phase 4/4.5. |
|
||||
| Container logs | Captured via docker-java, written to ClickHouse | Unified log query surface from day 1. Same pattern future app logs will use. |
|
||||
| Resource constraints | cgroups via docker-java `mem_limit` + `cpu_shares` | Protect the control plane from noisy neighbors. Tier-based defaults. Even in single-tenant Docker mode, a runaway Camel app shouldn't starve Traefik/Postgres/Logto. |
|
||||
| Orchestrator metadata | JSONB field on deployment entity | Docker stores `containerId`. K8s (Phase 5) stores `namespace`, `deploymentName`, `gitCommit`. Same table, different orchestrator. |
|
||||
|
||||
## Data Model
|
||||
|
||||
### Environment Entity
|
||||
|
||||
```sql
|
||||
CREATE TABLE environments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
slug VARCHAR(100) NOT NULL,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
bootstrap_token TEXT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(tenant_id, slug)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_environments_tenant_id ON environments(tenant_id);
|
||||
```
|
||||
|
||||
- `slug` — URL-safe, immutable, unique per tenant. Auto-created environment gets slug `default`.
|
||||
- `display_name` — User-editable. Auto-created environment gets `Default`.
|
||||
- `bootstrap_token` — The `CAMELEER_AUTH_TOKEN` value used for customer containers in this environment. In Docker mode, all environments share the same value (read from platform config). In K8s mode (Phase 5), can be unique per environment.
|
||||
- `status` — `ACTIVE` or `SUSPENDED`.
|
||||
|
||||
### App Entity
|
||||
|
||||
```sql
|
||||
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,
|
||||
jar_storage_path VARCHAR(500),
|
||||
jar_checksum VARCHAR(64),
|
||||
jar_original_filename VARCHAR(255),
|
||||
jar_size_bytes BIGINT,
|
||||
current_deployment_id UUID,
|
||||
previous_deployment_id UUID,
|
||||
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);
|
||||
```
|
||||
|
||||
- `slug` — URL-safe, immutable, unique per environment.
|
||||
- `jar_storage_path` — Relative path to uploaded JAR (e.g., `tenants/{tenant-slug}/envs/{env-slug}/apps/{app-slug}/app.jar`). Relative to the configured storage root (`cameleer.runtime.jar-storage-path`). Makes it easy to migrate the storage volume to a different mount point or cloud provider.
|
||||
- `jar_checksum` — SHA-256 hex digest of the uploaded JAR.
|
||||
- `current_deployment_id` — Points to the active deployment. Nullable (app created but never deployed).
|
||||
- `previous_deployment_id` — Points to the last known good deployment. When a new deploy succeeds, `current` becomes the new one and `previous` becomes the old `current`. When a deploy fails, `current` stays as the failed one but `previous` still points to the last good version, enabling a rollback button.
|
||||
|
||||
### Deployment Entity
|
||||
|
||||
```sql
|
||||
CREATE TABLE deployments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL,
|
||||
image_ref VARCHAR(500) NOT NULL,
|
||||
desired_status VARCHAR(20) NOT NULL DEFAULT 'RUNNING',
|
||||
observed_status VARCHAR(20) NOT NULL DEFAULT 'BUILDING',
|
||||
orchestrator_metadata JSONB DEFAULT '{}',
|
||||
error_message TEXT,
|
||||
deployed_at TIMESTAMPTZ,
|
||||
stopped_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(app_id, version)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_deployments_app_id ON deployments(app_id);
|
||||
```
|
||||
|
||||
- `version` — Sequential per app (1, 2, 3...). Incremented on each deploy.
|
||||
- `image_ref` — Docker image reference, e.g., `cameleer-runtime-{tenant}-{app}:v3`.
|
||||
- `desired_status` — What the user wants: `RUNNING`, `STOPPED`.
|
||||
- `observed_status` — What the platform sees: `BUILDING`, `STARTING`, `RUNNING`, `FAILED`, `STOPPED`.
|
||||
- `orchestrator_metadata` — Docker mode: `{"containerId": "abc123"}`. K8s mode (Phase 5): `{"namespace": "...", "deploymentName": "...", "gitCommit": "..."}`.
|
||||
- `error_message` — Populated when `observed_status` is `FAILED`. Build error, startup crash, etc.
|
||||
|
||||
## Component Architecture
|
||||
|
||||
### RuntimeOrchestrator Interface
|
||||
|
||||
```java
|
||||
public interface RuntimeOrchestrator {
|
||||
String buildImage(BuildImageRequest request);
|
||||
void startContainer(StartContainerRequest request);
|
||||
void stopContainer(String containerId);
|
||||
void removeContainer(String containerId);
|
||||
ContainerStatus getContainerStatus(String containerId);
|
||||
void streamLogs(String containerId, LogConsumer consumer);
|
||||
}
|
||||
```
|
||||
|
||||
- Single interface, implemented by `DockerRuntimeOrchestrator` (Phase 3) and `KubernetesRuntimeOrchestrator` (Phase 5).
|
||||
- Injected via Spring `@Profile` or `@ConditionalOnProperty`.
|
||||
- Request objects carry all context (image name, env vars, network, labels, etc.).
|
||||
|
||||
### DockerRuntimeOrchestrator
|
||||
|
||||
Uses `com.github.docker-java:docker-java` library. Connects via Docker socket (`/var/run/docker.sock`).
|
||||
|
||||
**buildImage:**
|
||||
1. Creates a temporary build context directory
|
||||
2. Writes a Dockerfile:
|
||||
```dockerfile
|
||||
FROM cameleer-runtime-base:{platform-version}
|
||||
COPY app.jar /app/app.jar
|
||||
```
|
||||
3. Copies the customer JAR as `app.jar`
|
||||
4. Calls `docker build` via docker-java
|
||||
5. Tags as `cameleer-runtime-{tenant-slug}-{app-slug}:v{version}`
|
||||
6. Returns the image reference
|
||||
|
||||
**startContainer:**
|
||||
1. Creates container with:
|
||||
- Image: the built image reference
|
||||
- Name: `{tenant-slug}-{env-slug}-{app-slug}`
|
||||
- Network: `cameleer` (the platform bridge network)
|
||||
- Environment variables:
|
||||
- `CAMELEER_AUTH_TOKEN={bootstrap-token}`
|
||||
- `CAMELEER_EXPORT_TYPE=HTTP`
|
||||
- `CAMELEER_EXPORT_ENDPOINT=http://cameleer3-server:8081`
|
||||
- `CAMELEER_APPLICATION_ID={app-slug}`
|
||||
- `CAMELEER_ENVIRONMENT_ID={env-slug}`
|
||||
- `CAMELEER_DISPLAY_NAME={tenant-slug}-{env-slug}-{app-slug}`
|
||||
- Resource constraints (cgroups):
|
||||
- `memory` / `memorySwap` — hard memory limit per container
|
||||
- `cpuShares` — relative CPU weight (default 512)
|
||||
- Defaults configurable via `cameleer.runtime.container-memory-limit` (default `512m`) and `cameleer.runtime.container-cpu-shares` (default `512`)
|
||||
- Protects the control plane (Traefik, Postgres, Logto, cameleer-saas) from noisy neighbor Camel apps
|
||||
- Health check: HTTP GET to agent health port 9464
|
||||
2. Starts container
|
||||
3. Returns container ID
|
||||
|
||||
**streamLogs:**
|
||||
- Attaches to container stdout/stderr via docker-java `LogContainerCmd`
|
||||
- Passes log lines to a `LogConsumer` callback (for ClickHouse ingestion)
|
||||
|
||||
### cameleer-runtime-base Image
|
||||
|
||||
A pre-built Docker image containing everything except the customer JAR:
|
||||
|
||||
```dockerfile
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
WORKDIR /app
|
||||
|
||||
COPY cameleer3-agent-{version}-shaded.jar /app/agent.jar
|
||||
|
||||
ENTRYPOINT exec java \
|
||||
-Dcameleer.export.type=${CAMELEER_EXPORT_TYPE:-HTTP} \
|
||||
-Dcameleer.export.endpoint=${CAMELEER_EXPORT_ENDPOINT} \
|
||||
-Dcameleer.agent.name=${HOSTNAME} \
|
||||
-Dcameleer.agent.application=${CAMELEER_APPLICATION_ID:-default} \
|
||||
-Dcameleer.agent.environment=${CAMELEER_ENVIRONMENT_ID:-default} \
|
||||
-Dcameleer.routeControl.enabled=${CAMELEER_ROUTE_CONTROL_ENABLED:-false} \
|
||||
-Dcameleer.replay.enabled=${CAMELEER_REPLAY_ENABLED:-false} \
|
||||
-Dcameleer.health.enabled=true \
|
||||
-Dcameleer.health.port=9464 \
|
||||
-javaagent:/app/agent.jar \
|
||||
-jar /app/app.jar
|
||||
```
|
||||
|
||||
- Built as part of the CI pipeline for cameleer-saas.
|
||||
- Published to Gitea registry: `gitea.siegeln.net/cameleer/cameleer-runtime-base:{version}`.
|
||||
- Version tracks the platform version + agent version (e.g., `0.2.0` includes agent `1.0-SNAPSHOT`).
|
||||
- Updating the agent JAR = rebuild this image with the new agent version → rebuild cameleer-saas image → all new deployments use the new agent.
|
||||
|
||||
### JAR Upload
|
||||
|
||||
- `POST /api/environments/{eid}/apps` with multipart file
|
||||
- Validation:
|
||||
- File extension: `.jar`
|
||||
- Max size: 200 MB (configurable via `cameleer.runtime.max-jar-size`)
|
||||
- SHA-256 checksum computed and stored
|
||||
- Storage: relative path `tenants/{tenant-slug}/envs/{env-slug}/apps/{app-slug}/app.jar` under the configured storage root (`cameleer.runtime.jar-storage-path`, default `/data/jars`)
|
||||
- Docker volume `jardata` mounted into cameleer-saas container
|
||||
- Database stores the relative path only — decoupled from mount point
|
||||
- JAR is overwritten on re-upload (new deploy uses new JAR)
|
||||
|
||||
### Async Deployment Pipeline
|
||||
|
||||
1. **API receives deploy request** → creates `Deployment` entity with `observed_status=BUILDING` → returns deployment ID (HTTP 202 Accepted)
|
||||
2. **Background thread** (Spring `@Async` with a bounded thread pool):
|
||||
a. Calls `orchestrator.buildImage(...)` → updates `observed_status=STARTING`
|
||||
b. Calls `orchestrator.startContainer(...)` → updates `observed_status=STARTING`
|
||||
c. Polls agent health endpoint (port 9464) with timeout → updates to `RUNNING` or `FAILED`
|
||||
d. On any failure → updates `observed_status=FAILED`, `error_message=...`
|
||||
3. **Client polls** `GET /api/apps/{aid}/deployments/{did}` for status updates
|
||||
4. **On success:** set `previous_deployment_id = old current_deployment_id`, then `current_deployment_id = new deployment`. Stop and remove the old container.
|
||||
5. **On failure:** `current_deployment_id` is set to the failed deployment (so status is visible), `previous_deployment_id` still points to the last known good version. Enables rollback.
|
||||
|
||||
### Container Logs → ClickHouse
|
||||
|
||||
- When a container starts, platform attaches a log consumer via `orchestrator.streamLogs()`
|
||||
- Log consumer batches lines and writes to ClickHouse table:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS container_logs (
|
||||
tenant_id UUID,
|
||||
environment_id UUID,
|
||||
app_id UUID,
|
||||
deployment_id UUID,
|
||||
timestamp DateTime64(3),
|
||||
stream String, -- 'stdout' or 'stderr'
|
||||
message String
|
||||
) ENGINE = MergeTree()
|
||||
ORDER BY (tenant_id, environment_id, app_id, timestamp);
|
||||
```
|
||||
|
||||
- Logs retrieved via `GET /api/apps/{aid}/logs?since=...&limit=...` which queries ClickHouse
|
||||
- ClickHouse TTL can enforce retention based on license `retention_days` limit (future enhancement)
|
||||
|
||||
### Bootstrap Token Handling
|
||||
|
||||
In Docker single-tenant mode, all environments share the single cameleer3-server instance and its single `CAMELEER_AUTH_TOKEN`. The platform reads this token from its own configuration (`cameleer.runtime.bootstrap-token` / `CAMELEER_AUTH_TOKEN` env var) and injects it into every customer container. No changes to cameleer3-server are needed.
|
||||
|
||||
Environment-level data separation happens at the agent registration level — the agent sends its `environmentId` claim when it registers, and cameleer3-server uses that to scope all data. The bootstrap token is the same across environments in a Docker stack.
|
||||
|
||||
The `bootstrap_token` column on the environment entity stores the token value used for that environment's containers. In Docker mode this is the same shared value for all environments. In K8s mode (Phase 5), each environment could have its own cameleer3-server instance with a unique token, enabling true per-environment token isolation.
|
||||
|
||||
## API Surface
|
||||
|
||||
### Environment Endpoints
|
||||
|
||||
```
|
||||
POST /api/tenants/{tenantId}/environments
|
||||
Body: { "slug": "dev", "displayName": "Development" }
|
||||
Returns: 201 Created + EnvironmentResponse
|
||||
Enforces: tier-based max_environments limit from license
|
||||
|
||||
GET /api/tenants/{tenantId}/environments
|
||||
Returns: 200 + List<EnvironmentResponse>
|
||||
|
||||
GET /api/tenants/{tenantId}/environments/{environmentId}
|
||||
Returns: 200 + EnvironmentResponse
|
||||
|
||||
PATCH /api/tenants/{tenantId}/environments/{environmentId}
|
||||
Body: { "displayName": "New Name" }
|
||||
Returns: 200 + EnvironmentResponse
|
||||
|
||||
DELETE /api/tenants/{tenantId}/environments/{environmentId}
|
||||
Returns: 204 No Content
|
||||
Precondition: no running apps in environment
|
||||
Restriction: cannot delete the auto-created "default" environment
|
||||
```
|
||||
|
||||
### App Endpoints
|
||||
|
||||
```
|
||||
POST /api/environments/{environmentId}/apps
|
||||
Multipart: file (JAR) + metadata { "slug": "order-service", "displayName": "Order Service" }
|
||||
Returns: 201 Created + AppResponse
|
||||
Validates: file extension, size, checksum
|
||||
|
||||
GET /api/environments/{environmentId}/apps
|
||||
Returns: 200 + List<AppResponse>
|
||||
|
||||
GET /api/environments/{environmentId}/apps/{appId}
|
||||
Returns: 200 + AppResponse (includes current deployment status)
|
||||
|
||||
PUT /api/environments/{environmentId}/apps/{appId}/jar
|
||||
Multipart: file (JAR)
|
||||
Returns: 200 + AppResponse
|
||||
Purpose: re-upload JAR without creating new app
|
||||
|
||||
DELETE /api/environments/{environmentId}/apps/{appId}
|
||||
Returns: 204 No Content
|
||||
Side effect: stops running container, removes image
|
||||
```
|
||||
|
||||
### Deployment Endpoints
|
||||
|
||||
```
|
||||
POST /api/apps/{appId}/deploy
|
||||
Body: {} (empty — uses current JAR)
|
||||
Returns: 202 Accepted + DeploymentResponse (with deployment ID, status=BUILDING)
|
||||
|
||||
GET /api/apps/{appId}/deployments
|
||||
Returns: 200 + List<DeploymentResponse> (ordered by version desc)
|
||||
|
||||
GET /api/apps/{appId}/deployments/{deploymentId}
|
||||
Returns: 200 + DeploymentResponse (poll this for status updates)
|
||||
|
||||
POST /api/apps/{appId}/stop
|
||||
Returns: 200 + DeploymentResponse (desired_status=STOPPED)
|
||||
|
||||
POST /api/apps/{appId}/restart
|
||||
Returns: 202 Accepted + DeploymentResponse (stops + redeploys same image)
|
||||
```
|
||||
|
||||
### Log Endpoints
|
||||
|
||||
```
|
||||
GET /api/apps/{appId}/logs
|
||||
Query: since (ISO timestamp), until (ISO timestamp), limit (default 500), stream (stdout/stderr/both)
|
||||
Returns: 200 + List<LogEntry>
|
||||
Source: ClickHouse container_logs table
|
||||
```
|
||||
|
||||
## Tier Enforcement
|
||||
|
||||
| Tier | max_environments | max_agents (apps) |
|
||||
|------|-----------------|-------------------|
|
||||
| LOW | 1 | 3 |
|
||||
| MID | 2 | 10 |
|
||||
| HIGH | unlimited (-1) | 50 |
|
||||
| BUSINESS | unlimited (-1) | unlimited (-1) |
|
||||
|
||||
- `max_environments` enforced on `POST /api/tenants/{tid}/environments`. The auto-created `default` environment counts toward the limit.
|
||||
- `max_agents` enforced on `POST /api/environments/{eid}/apps`. Count is total apps across all environments in the tenant.
|
||||
|
||||
## Docker Compose Changes
|
||||
|
||||
The cameleer-saas service needs:
|
||||
- Docker socket mount: `/var/run/docker.sock:/var/run/docker.sock` (already present in docker-compose.yml)
|
||||
- JAR storage volume: `jardata:/data/jars`
|
||||
- `cameleer-runtime-base` image must be available (pre-pulled or built locally)
|
||||
|
||||
The cameleer3-server `CAMELEER_AUTH_TOKEN` is read by cameleer-saas from shared environment config and injected into customer containers.
|
||||
|
||||
New volume in docker-compose.yml:
|
||||
```yaml
|
||||
volumes:
|
||||
jardata:
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
### New Maven Dependencies
|
||||
|
||||
```xml
|
||||
<!-- Docker Java client -->
|
||||
<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-httpclient5</artifactId>
|
||||
<version>3.4.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- ClickHouse JDBC -->
|
||||
<dependency>
|
||||
<groupId>com.clickhouse</groupId>
|
||||
<artifactId>clickhouse-jdbc</artifactId>
|
||||
<version>0.7.1</version>
|
||||
<classifier>all</classifier>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
### New Configuration Properties
|
||||
|
||||
```yaml
|
||||
cameleer:
|
||||
runtime:
|
||||
max-jar-size: 209715200 # 200 MB
|
||||
jar-storage-path: /data/jars
|
||||
base-image: cameleer-runtime-base:latest
|
||||
docker-network: cameleer
|
||||
agent-health-port: 9464
|
||||
health-check-timeout: 60 # seconds to wait for healthy status
|
||||
deployment-thread-pool-size: 4
|
||||
container-memory-limit: 512m # per customer container
|
||||
container-cpu-shares: 512 # relative weight (default Docker is 1024)
|
||||
clickhouse:
|
||||
url: jdbc:clickhouse://clickhouse:8123/cameleer
|
||||
```
|
||||
|
||||
## Verification Plan
|
||||
|
||||
1. Upload a sample Camel JAR via `POST /api/environments/{eid}/apps`
|
||||
2. Deploy via `POST /api/apps/{aid}/deploy` — returns 202 with deployment ID
|
||||
3. Poll `GET /api/apps/{aid}/deployments/{did}` — status transitions: `BUILDING` → `STARTING` → `RUNNING`
|
||||
4. Container visible in `docker ps` as `{tenant}-{env}-{app}`
|
||||
5. Container is on the `cameleer` network
|
||||
6. cameleer3 agent registers with cameleer3-server (visible in server logs)
|
||||
7. Agent health endpoint responds on port 9464
|
||||
8. Container logs appear in ClickHouse `container_logs` table
|
||||
9. `GET /api/apps/{aid}/logs` returns log entries
|
||||
10. `POST /api/apps/{aid}/stop` stops the container, status becomes `STOPPED`
|
||||
11. `POST /api/apps/{aid}/restart` restarts with same image
|
||||
12. Re-upload JAR + redeploy creates deployment v2, stops v1
|
||||
13. Tier limits enforced: LOW tenant cannot create more than 1 environment or 3 apps
|
||||
14. Default environment auto-created on tenant provisioning
|
||||
20
pom.xml
20
pom.xml
@@ -80,6 +80,26 @@
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Docker Java client -->
|
||||
<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-httpclient5</artifactId>
|
||||
<version>3.4.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- ClickHouse JDBC -->
|
||||
<dependency>
|
||||
<groupId>com.clickhouse</groupId>
|
||||
<artifactId>clickhouse-jdbc</artifactId>
|
||||
<version>0.7.1</version>
|
||||
<classifier>all</classifier>
|
||||
</dependency>
|
||||
|
||||
<!-- Test -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
|
||||
130
src/main/java/net/siegeln/cameleer/saas/app/AppController.java
Normal file
130
src/main/java/net/siegeln/cameleer/saas/app/AppController.java
Normal file
@@ -0,0 +1,130 @@
|
||||
package net.siegeln.cameleer.saas.app;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import net.siegeln.cameleer.saas.app.dto.AppResponse;
|
||||
import net.siegeln.cameleer.saas.app.dto.CreateAppRequest;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/environments/{environmentId}/apps")
|
||||
public class AppController {
|
||||
|
||||
private final AppService appService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public AppController(AppService appService, ObjectMapper objectMapper) {
|
||||
this.appService = appService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data")
|
||||
public ResponseEntity<AppResponse> create(
|
||||
@PathVariable UUID environmentId,
|
||||
@RequestPart("metadata") String metadataJson,
|
||||
@RequestPart("file") MultipartFile file,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
var request = objectMapper.readValue(metadataJson, CreateAppRequest.class);
|
||||
UUID actorId = resolveActorId(authentication);
|
||||
var entity = appService.create(environmentId, request.slug(), request.displayName(), file, actorId);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(entity));
|
||||
} catch (IllegalArgumentException e) {
|
||||
var msg = e.getMessage();
|
||||
if (msg != null && (msg.contains("already exists") || msg.contains("slug"))) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
||||
}
|
||||
return ResponseEntity.badRequest().build();
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
} catch (IOException e) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<AppResponse>> list(@PathVariable UUID environmentId) {
|
||||
var apps = appService.listByEnvironmentId(environmentId)
|
||||
.stream()
|
||||
.map(this::toResponse)
|
||||
.toList();
|
||||
return ResponseEntity.ok(apps);
|
||||
}
|
||||
|
||||
@GetMapping("/{appId}")
|
||||
public ResponseEntity<AppResponse> getById(
|
||||
@PathVariable UUID environmentId,
|
||||
@PathVariable UUID appId) {
|
||||
return appService.getById(appId)
|
||||
.map(entity -> ResponseEntity.ok(toResponse(entity)))
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@PutMapping(value = "/{appId}/jar", consumes = "multipart/form-data")
|
||||
public ResponseEntity<AppResponse> reuploadJar(
|
||||
@PathVariable UUID environmentId,
|
||||
@PathVariable UUID appId,
|
||||
@RequestPart("file") MultipartFile file,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
UUID actorId = resolveActorId(authentication);
|
||||
var entity = appService.reuploadJar(appId, file, actorId);
|
||||
return ResponseEntity.ok(toResponse(entity));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/{appId}")
|
||||
public ResponseEntity<Void> delete(
|
||||
@PathVariable UUID environmentId,
|
||||
@PathVariable UUID appId,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
UUID actorId = resolveActorId(authentication);
|
||||
appService.delete(appId, actorId);
|
||||
return ResponseEntity.noContent().build();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
|
||||
private UUID resolveActorId(Authentication authentication) {
|
||||
String sub = authentication.getName();
|
||||
try {
|
||||
return UUID.fromString(sub);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return UUID.nameUUIDFromBytes(sub.getBytes());
|
||||
}
|
||||
}
|
||||
|
||||
private AppResponse toResponse(AppEntity entity) {
|
||||
return new AppResponse(
|
||||
entity.getId(),
|
||||
entity.getEnvironmentId(),
|
||||
entity.getSlug(),
|
||||
entity.getDisplayName(),
|
||||
entity.getJarOriginalFilename(),
|
||||
entity.getJarSizeBytes(),
|
||||
entity.getJarChecksum(),
|
||||
entity.getCurrentDeploymentId(),
|
||||
entity.getPreviousDeploymentId(),
|
||||
entity.getCreatedAt(),
|
||||
entity.getUpdatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
81
src/main/java/net/siegeln/cameleer/saas/app/AppEntity.java
Normal file
81
src/main/java/net/siegeln/cameleer/saas/app/AppEntity.java
Normal file
@@ -0,0 +1,81 @@
|
||||
package net.siegeln.cameleer.saas.app;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "apps")
|
||||
public class AppEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "environment_id", nullable = false)
|
||||
private UUID environmentId;
|
||||
|
||||
@Column(nullable = false, length = 100)
|
||||
private String slug;
|
||||
|
||||
@Column(name = "display_name", nullable = false)
|
||||
private String displayName;
|
||||
|
||||
@Column(name = "jar_storage_path", length = 500)
|
||||
private String jarStoragePath;
|
||||
|
||||
@Column(name = "jar_checksum", length = 64)
|
||||
private String jarChecksum;
|
||||
|
||||
@Column(name = "jar_original_filename")
|
||||
private String jarOriginalFilename;
|
||||
|
||||
@Column(name = "jar_size_bytes")
|
||||
private Long jarSizeBytes;
|
||||
|
||||
@Column(name = "current_deployment_id")
|
||||
private UUID currentDeploymentId;
|
||||
|
||||
@Column(name = "previous_deployment_id")
|
||||
private UUID previousDeploymentId;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = Instant.now();
|
||||
updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public void setId(UUID id) { this.id = id; }
|
||||
public UUID getEnvironmentId() { return environmentId; }
|
||||
public void setEnvironmentId(UUID environmentId) { this.environmentId = environmentId; }
|
||||
public String getSlug() { return slug; }
|
||||
public void setSlug(String slug) { this.slug = slug; }
|
||||
public String getDisplayName() { return displayName; }
|
||||
public void setDisplayName(String displayName) { this.displayName = displayName; }
|
||||
public String getJarStoragePath() { return jarStoragePath; }
|
||||
public void setJarStoragePath(String jarStoragePath) { this.jarStoragePath = jarStoragePath; }
|
||||
public String getJarChecksum() { return jarChecksum; }
|
||||
public void setJarChecksum(String jarChecksum) { this.jarChecksum = jarChecksum; }
|
||||
public String getJarOriginalFilename() { return jarOriginalFilename; }
|
||||
public void setJarOriginalFilename(String jarOriginalFilename) { this.jarOriginalFilename = jarOriginalFilename; }
|
||||
public Long getJarSizeBytes() { return jarSizeBytes; }
|
||||
public void setJarSizeBytes(Long jarSizeBytes) { this.jarSizeBytes = jarSizeBytes; }
|
||||
public UUID getCurrentDeploymentId() { return currentDeploymentId; }
|
||||
public void setCurrentDeploymentId(UUID currentDeploymentId) { this.currentDeploymentId = currentDeploymentId; }
|
||||
public UUID getPreviousDeploymentId() { return previousDeploymentId; }
|
||||
public void setPreviousDeploymentId(UUID previousDeploymentId) { this.previousDeploymentId = previousDeploymentId; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
public Instant getUpdatedAt() { return updatedAt; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package net.siegeln.cameleer.saas.app;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface AppRepository extends JpaRepository<AppEntity, UUID> {
|
||||
|
||||
List<AppEntity> findByEnvironmentId(UUID environmentId);
|
||||
|
||||
Optional<AppEntity> findByEnvironmentIdAndSlug(UUID environmentId, String slug);
|
||||
|
||||
boolean existsByEnvironmentIdAndSlug(UUID environmentId, String slug);
|
||||
|
||||
@Query("SELECT COUNT(a) FROM AppEntity a JOIN EnvironmentEntity e ON a.environmentId = e.id WHERE e.tenantId = :tenantId")
|
||||
long countByTenantId(UUID tenantId);
|
||||
|
||||
long countByEnvironmentId(UUID environmentId);
|
||||
}
|
||||
161
src/main/java/net/siegeln/cameleer/saas/app/AppService.java
Normal file
161
src/main/java/net/siegeln/cameleer/saas/app/AppService.java
Normal file
@@ -0,0 +1,161 @@
|
||||
package net.siegeln.cameleer.saas.app;
|
||||
|
||||
import net.siegeln.cameleer.saas.audit.AuditAction;
|
||||
import net.siegeln.cameleer.saas.audit.AuditService;
|
||||
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
|
||||
import net.siegeln.cameleer.saas.license.LicenseDefaults;
|
||||
import net.siegeln.cameleer.saas.license.LicenseRepository;
|
||||
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
|
||||
import net.siegeln.cameleer.saas.tenant.Tier;
|
||||
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.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class AppService {
|
||||
|
||||
private final AppRepository appRepository;
|
||||
private final EnvironmentRepository environmentRepository;
|
||||
private final LicenseRepository licenseRepository;
|
||||
private final AuditService auditService;
|
||||
private final RuntimeConfig runtimeConfig;
|
||||
|
||||
public AppService(AppRepository appRepository,
|
||||
EnvironmentRepository environmentRepository,
|
||||
LicenseRepository licenseRepository,
|
||||
AuditService auditService,
|
||||
RuntimeConfig runtimeConfig) {
|
||||
this.appRepository = appRepository;
|
||||
this.environmentRepository = environmentRepository;
|
||||
this.licenseRepository = licenseRepository;
|
||||
this.auditService = auditService;
|
||||
this.runtimeConfig = runtimeConfig;
|
||||
}
|
||||
|
||||
public AppEntity create(UUID envId, String slug, String displayName, MultipartFile jarFile, UUID actorId) {
|
||||
validateJarFile(jarFile);
|
||||
|
||||
var env = environmentRepository.findById(envId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Environment not found: " + envId));
|
||||
|
||||
if (appRepository.existsByEnvironmentIdAndSlug(envId, slug)) {
|
||||
throw new IllegalArgumentException("App slug already exists in this environment: " + slug);
|
||||
}
|
||||
|
||||
var tenantId = env.getTenantId();
|
||||
enforceTierLimit(tenantId);
|
||||
|
||||
var relativePath = "tenants/" + tenantId + "/envs/" + env.getSlug() + "/apps/" + slug + "/app.jar";
|
||||
var checksum = storeJar(jarFile, relativePath);
|
||||
|
||||
var entity = new AppEntity();
|
||||
entity.setEnvironmentId(envId);
|
||||
entity.setSlug(slug);
|
||||
entity.setDisplayName(displayName);
|
||||
entity.setJarStoragePath(relativePath);
|
||||
entity.setJarChecksum(checksum);
|
||||
entity.setJarOriginalFilename(jarFile.getOriginalFilename());
|
||||
entity.setJarSizeBytes(jarFile.getSize());
|
||||
|
||||
var saved = appRepository.save(entity);
|
||||
|
||||
auditService.log(actorId, null, tenantId,
|
||||
AuditAction.APP_CREATE, slug,
|
||||
null, null, "SUCCESS", null);
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
public AppEntity reuploadJar(UUID appId, MultipartFile jarFile, UUID actorId) {
|
||||
validateJarFile(jarFile);
|
||||
|
||||
var entity = appRepository.findById(appId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("App not found: " + appId));
|
||||
|
||||
var checksum = storeJar(jarFile, entity.getJarStoragePath());
|
||||
|
||||
entity.setJarChecksum(checksum);
|
||||
entity.setJarOriginalFilename(jarFile.getOriginalFilename());
|
||||
entity.setJarSizeBytes(jarFile.getSize());
|
||||
|
||||
return appRepository.save(entity);
|
||||
}
|
||||
|
||||
public List<AppEntity> listByEnvironmentId(UUID envId) {
|
||||
return appRepository.findByEnvironmentId(envId);
|
||||
}
|
||||
|
||||
public Optional<AppEntity> getById(UUID id) {
|
||||
return appRepository.findById(id);
|
||||
}
|
||||
|
||||
public void delete(UUID appId, UUID actorId) {
|
||||
var entity = appRepository.findById(appId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("App not found: " + appId));
|
||||
|
||||
appRepository.delete(entity);
|
||||
|
||||
var env = environmentRepository.findById(entity.getEnvironmentId()).orElse(null);
|
||||
var tenantId = env != null ? env.getTenantId() : null;
|
||||
|
||||
auditService.log(actorId, null, tenantId,
|
||||
AuditAction.APP_DELETE, entity.getSlug(),
|
||||
null, null, "SUCCESS", null);
|
||||
}
|
||||
|
||||
public Path resolveJarPath(String relativePath) {
|
||||
return Path.of(runtimeConfig.getJarStoragePath()).resolve(relativePath);
|
||||
}
|
||||
|
||||
private void validateJarFile(MultipartFile jarFile) {
|
||||
var filename = jarFile.getOriginalFilename();
|
||||
if (filename == null || !filename.toLowerCase().endsWith(".jar")) {
|
||||
throw new IllegalArgumentException("File must be a .jar file");
|
||||
}
|
||||
if (jarFile.getSize() > runtimeConfig.getMaxJarSize()) {
|
||||
throw new IllegalArgumentException("JAR file exceeds maximum allowed size");
|
||||
}
|
||||
}
|
||||
|
||||
private String storeJar(MultipartFile file, String relativePath) {
|
||||
try {
|
||||
var targetPath = resolveJarPath(relativePath);
|
||||
Files.createDirectories(targetPath.getParent());
|
||||
Files.copy(file.getInputStream(), targetPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
return computeSha256(file);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Failed to store JAR file", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String computeSha256(MultipartFile file) {
|
||||
try {
|
||||
var digest = MessageDigest.getInstance("SHA-256");
|
||||
var hash = digest.digest(file.getBytes());
|
||||
return HexFormat.of().formatHex(hash);
|
||||
} catch (NoSuchAlgorithmException | IOException e) {
|
||||
throw new IllegalStateException("Failed to compute SHA-256 checksum", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void enforceTierLimit(UUID tenantId) {
|
||||
var license = licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId);
|
||||
if (license.isEmpty()) {
|
||||
throw new IllegalStateException("No active license");
|
||||
}
|
||||
var limits = LicenseDefaults.limitsForTier(Tier.valueOf(license.get().getTier()));
|
||||
var maxApps = (int) limits.getOrDefault("max_agents", 3);
|
||||
if (maxApps != -1 && appRepository.countByTenantId(tenantId) >= maxApps) {
|
||||
throw new IllegalStateException("App limit reached for current tier");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package net.siegeln.cameleer.saas.app.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public record AppResponse(
|
||||
UUID id, UUID environmentId, String slug, String displayName,
|
||||
String jarOriginalFilename, Long jarSizeBytes, String jarChecksum,
|
||||
UUID currentDeploymentId, UUID previousDeploymentId,
|
||||
Instant createdAt, Instant updatedAt
|
||||
) {}
|
||||
@@ -0,0 +1,13 @@
|
||||
package net.siegeln.cameleer.saas.app.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record CreateAppRequest(
|
||||
@NotBlank @Size(min = 2, max = 100)
|
||||
@Pattern(regexp = "^[a-z0-9][a-z0-9-]*[a-z0-9]$", message = "Slug must be lowercase alphanumeric with hyphens")
|
||||
String slug,
|
||||
@NotBlank @Size(max = 255)
|
||||
String displayName
|
||||
) {}
|
||||
@@ -3,6 +3,7 @@ package net.siegeln.cameleer.saas.audit;
|
||||
public enum AuditAction {
|
||||
AUTH_REGISTER, AUTH_LOGIN, AUTH_LOGIN_FAILED, AUTH_LOGOUT,
|
||||
TENANT_CREATE, TENANT_UPDATE, TENANT_SUSPEND, TENANT_REACTIVATE, TENANT_DELETE,
|
||||
ENVIRONMENT_CREATE, ENVIRONMENT_UPDATE, ENVIRONMENT_DELETE,
|
||||
APP_CREATE, APP_DEPLOY, APP_PROMOTE, APP_ROLLBACK, APP_SCALE, APP_STOP, APP_DELETE,
|
||||
SECRET_CREATE, SECRET_READ, SECRET_UPDATE, SECRET_DELETE, SECRET_ROTATE,
|
||||
CONFIG_UPDATE,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package net.siegeln.cameleer.saas.config;
|
||||
|
||||
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
@Configuration
|
||||
@EnableAsync
|
||||
public class AsyncConfig {
|
||||
|
||||
private final RuntimeConfig runtimeConfig;
|
||||
|
||||
public AsyncConfig(RuntimeConfig runtimeConfig) {
|
||||
this.runtimeConfig = runtimeConfig;
|
||||
}
|
||||
|
||||
@Bean(name = "deploymentExecutor")
|
||||
public Executor deploymentExecutor() {
|
||||
var executor = new ThreadPoolTaskExecutor();
|
||||
// Core == max: no burst threads. Deployments beyond pool size queue (up to 25).
|
||||
executor.setCorePoolSize(runtimeConfig.getDeploymentThreadPoolSize());
|
||||
executor.setMaxPoolSize(runtimeConfig.getDeploymentThreadPoolSize());
|
||||
executor.setQueueCapacity(25);
|
||||
executor.setThreadNamePrefix("deploy-");
|
||||
executor.initialize();
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package net.siegeln.cameleer.saas.deployment;
|
||||
|
||||
import net.siegeln.cameleer.saas.deployment.dto.DeploymentResponse;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/apps/{appId}")
|
||||
public class DeploymentController {
|
||||
|
||||
private final DeploymentService deploymentService;
|
||||
|
||||
public DeploymentController(DeploymentService deploymentService) {
|
||||
this.deploymentService = deploymentService;
|
||||
}
|
||||
|
||||
@PostMapping("/deploy")
|
||||
public ResponseEntity<DeploymentResponse> deploy(
|
||||
@PathVariable UUID appId,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
UUID actorId = resolveActorId(authentication);
|
||||
var entity = deploymentService.deploy(appId, actorId);
|
||||
return ResponseEntity.status(HttpStatus.ACCEPTED).body(toResponse(entity));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.notFound().build();
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/deployments")
|
||||
public ResponseEntity<List<DeploymentResponse>> listDeployments(@PathVariable UUID appId) {
|
||||
var deployments = deploymentService.listByAppId(appId)
|
||||
.stream()
|
||||
.map(this::toResponse)
|
||||
.toList();
|
||||
return ResponseEntity.ok(deployments);
|
||||
}
|
||||
|
||||
@GetMapping("/deployments/{deploymentId}")
|
||||
public ResponseEntity<DeploymentResponse> getDeployment(
|
||||
@PathVariable UUID appId,
|
||||
@PathVariable UUID deploymentId) {
|
||||
return deploymentService.getById(deploymentId)
|
||||
.map(entity -> ResponseEntity.ok(toResponse(entity)))
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@PostMapping("/stop")
|
||||
public ResponseEntity<DeploymentResponse> stop(
|
||||
@PathVariable UUID appId,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
UUID actorId = resolveActorId(authentication);
|
||||
var entity = deploymentService.stop(appId, actorId);
|
||||
return ResponseEntity.ok(toResponse(entity));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.notFound().build();
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/restart")
|
||||
public ResponseEntity<DeploymentResponse> restart(
|
||||
@PathVariable UUID appId,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
UUID actorId = resolveActorId(authentication);
|
||||
var entity = deploymentService.restart(appId, actorId);
|
||||
return ResponseEntity.status(HttpStatus.ACCEPTED).body(toResponse(entity));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.notFound().build();
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
}
|
||||
|
||||
private UUID resolveActorId(Authentication authentication) {
|
||||
String sub = authentication.getName();
|
||||
try {
|
||||
return UUID.fromString(sub);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return UUID.nameUUIDFromBytes(sub.getBytes());
|
||||
}
|
||||
}
|
||||
|
||||
private DeploymentResponse toResponse(DeploymentEntity entity) {
|
||||
return new DeploymentResponse(
|
||||
entity.getId(),
|
||||
entity.getAppId(),
|
||||
entity.getVersion(),
|
||||
entity.getImageRef(),
|
||||
entity.getDesiredStatus().name(),
|
||||
entity.getObservedStatus().name(),
|
||||
entity.getErrorMessage(),
|
||||
entity.getOrchestratorMetadata(),
|
||||
entity.getDeployedAt(),
|
||||
entity.getStoppedAt(),
|
||||
entity.getCreatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package net.siegeln.cameleer.saas.deployment;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "deployments")
|
||||
public class DeploymentEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "app_id", nullable = false)
|
||||
private UUID appId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int version;
|
||||
|
||||
@Column(name = "image_ref", nullable = false, length = 500)
|
||||
private String imageRef;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "desired_status", nullable = false, length = 20)
|
||||
private DesiredStatus desiredStatus = DesiredStatus.RUNNING;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "observed_status", nullable = false, length = 20)
|
||||
private ObservedStatus observedStatus = ObservedStatus.BUILDING;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "orchestrator_metadata")
|
||||
private Map<String, Object> orchestratorMetadata = Map.of();
|
||||
|
||||
@Column(name = "error_message", columnDefinition = "TEXT")
|
||||
private String errorMessage;
|
||||
|
||||
@Column(name = "deployed_at")
|
||||
private Instant deployedAt;
|
||||
|
||||
@Column(name = "stopped_at")
|
||||
private Instant stoppedAt;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = Instant.now();
|
||||
}
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public void setId(UUID id) { this.id = id; }
|
||||
|
||||
public UUID getAppId() { return appId; }
|
||||
public void setAppId(UUID appId) { this.appId = appId; }
|
||||
|
||||
public int getVersion() { return version; }
|
||||
public void setVersion(int version) { this.version = version; }
|
||||
|
||||
public String getImageRef() { return imageRef; }
|
||||
public void setImageRef(String imageRef) { this.imageRef = imageRef; }
|
||||
|
||||
public DesiredStatus getDesiredStatus() { return desiredStatus; }
|
||||
public void setDesiredStatus(DesiredStatus desiredStatus) { this.desiredStatus = desiredStatus; }
|
||||
|
||||
public ObservedStatus getObservedStatus() { return observedStatus; }
|
||||
public void setObservedStatus(ObservedStatus observedStatus) { this.observedStatus = observedStatus; }
|
||||
|
||||
public Map<String, Object> getOrchestratorMetadata() { return orchestratorMetadata; }
|
||||
public void setOrchestratorMetadata(Map<String, Object> orchestratorMetadata) { this.orchestratorMetadata = orchestratorMetadata; }
|
||||
|
||||
public String getErrorMessage() { return errorMessage; }
|
||||
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
|
||||
|
||||
public Instant getDeployedAt() { return deployedAt; }
|
||||
public void setDeployedAt(Instant deployedAt) { this.deployedAt = deployedAt; }
|
||||
|
||||
public Instant getStoppedAt() { return stoppedAt; }
|
||||
public void setStoppedAt(Instant stoppedAt) { this.stoppedAt = stoppedAt; }
|
||||
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package net.siegeln.cameleer.saas.deployment;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface DeploymentRepository extends JpaRepository<DeploymentEntity, UUID> {
|
||||
List<DeploymentEntity> findByAppIdOrderByVersionDesc(UUID appId);
|
||||
|
||||
@Query("SELECT COALESCE(MAX(d.version), 0) FROM DeploymentEntity d WHERE d.appId = :appId")
|
||||
int findMaxVersionByAppId(UUID appId);
|
||||
|
||||
Optional<DeploymentEntity> findByAppIdAndVersion(UUID appId, int version);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package net.siegeln.cameleer.saas.deployment;
|
||||
|
||||
import net.siegeln.cameleer.saas.app.AppEntity;
|
||||
import net.siegeln.cameleer.saas.app.AppRepository;
|
||||
import net.siegeln.cameleer.saas.app.AppService;
|
||||
import net.siegeln.cameleer.saas.audit.AuditAction;
|
||||
import net.siegeln.cameleer.saas.audit.AuditService;
|
||||
import net.siegeln.cameleer.saas.environment.EnvironmentEntity;
|
||||
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
|
||||
import net.siegeln.cameleer.saas.runtime.BuildImageRequest;
|
||||
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
|
||||
import net.siegeln.cameleer.saas.runtime.RuntimeOrchestrator;
|
||||
import net.siegeln.cameleer.saas.runtime.StartContainerRequest;
|
||||
import net.siegeln.cameleer.saas.tenant.TenantRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class DeploymentService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DeploymentService.class);
|
||||
|
||||
private final DeploymentRepository deploymentRepository;
|
||||
private final AppRepository appRepository;
|
||||
private final AppService appService;
|
||||
private final EnvironmentRepository environmentRepository;
|
||||
private final TenantRepository tenantRepository;
|
||||
private final RuntimeOrchestrator runtimeOrchestrator;
|
||||
private final RuntimeConfig runtimeConfig;
|
||||
private final AuditService auditService;
|
||||
|
||||
public DeploymentService(DeploymentRepository deploymentRepository,
|
||||
AppRepository appRepository,
|
||||
AppService appService,
|
||||
EnvironmentRepository environmentRepository,
|
||||
TenantRepository tenantRepository,
|
||||
RuntimeOrchestrator runtimeOrchestrator,
|
||||
RuntimeConfig runtimeConfig,
|
||||
AuditService auditService) {
|
||||
this.deploymentRepository = deploymentRepository;
|
||||
this.appRepository = appRepository;
|
||||
this.appService = appService;
|
||||
this.environmentRepository = environmentRepository;
|
||||
this.tenantRepository = tenantRepository;
|
||||
this.runtimeOrchestrator = runtimeOrchestrator;
|
||||
this.runtimeConfig = runtimeConfig;
|
||||
this.auditService = auditService;
|
||||
}
|
||||
|
||||
public DeploymentEntity deploy(UUID appId, UUID actorId) {
|
||||
var app = appRepository.findById(appId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("App not found: " + appId));
|
||||
|
||||
if (app.getJarStoragePath() == null) {
|
||||
throw new IllegalStateException("App has no JAR uploaded: " + appId);
|
||||
}
|
||||
|
||||
var env = environmentRepository.findById(app.getEnvironmentId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Environment not found: " + app.getEnvironmentId()));
|
||||
|
||||
int nextVersion = deploymentRepository.findMaxVersionByAppId(appId) + 1;
|
||||
|
||||
var imageRef = "cameleer-runtime-" + env.getSlug() + "-" + app.getSlug() + ":v" + nextVersion;
|
||||
|
||||
var deployment = new DeploymentEntity();
|
||||
deployment.setAppId(appId);
|
||||
deployment.setVersion(nextVersion);
|
||||
deployment.setImageRef(imageRef);
|
||||
deployment.setObservedStatus(ObservedStatus.BUILDING);
|
||||
deployment.setDesiredStatus(DesiredStatus.RUNNING);
|
||||
|
||||
var saved = deploymentRepository.save(deployment);
|
||||
|
||||
auditService.log(actorId, null, env.getTenantId(),
|
||||
AuditAction.APP_DEPLOY, app.getSlug(),
|
||||
env.getSlug(), null, "SUCCESS", null);
|
||||
|
||||
executeDeploymentAsync(saved.getId(), app, env);
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Async("deploymentExecutor")
|
||||
public void executeDeploymentAsync(UUID deploymentId, AppEntity app, EnvironmentEntity env) {
|
||||
var deployment = deploymentRepository.findById(deploymentId).orElse(null);
|
||||
if (deployment == null) {
|
||||
log.error("Deployment not found for async execution: {}", deploymentId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var jarPath = appService.resolveJarPath(app.getJarStoragePath());
|
||||
|
||||
runtimeOrchestrator.buildImage(new BuildImageRequest(
|
||||
runtimeConfig.getBaseImage(),
|
||||
jarPath,
|
||||
deployment.getImageRef()
|
||||
));
|
||||
|
||||
deployment.setObservedStatus(ObservedStatus.STARTING);
|
||||
deploymentRepository.save(deployment);
|
||||
|
||||
var tenant = tenantRepository.findById(env.getTenantId()).orElse(null);
|
||||
var tenantSlug = tenant != null ? tenant.getSlug() : env.getTenantId().toString();
|
||||
var containerName = tenantSlug + "-" + env.getSlug() + "-" + app.getSlug();
|
||||
|
||||
if (app.getCurrentDeploymentId() != null) {
|
||||
deploymentRepository.findById(app.getCurrentDeploymentId()).ifPresent(oldDeployment -> {
|
||||
var oldMetadata = oldDeployment.getOrchestratorMetadata();
|
||||
if (oldMetadata != null && oldMetadata.containsKey("containerId")) {
|
||||
var oldContainerId = (String) oldMetadata.get("containerId");
|
||||
try {
|
||||
runtimeOrchestrator.stopContainer(oldContainerId);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to stop old container {}: {}", oldContainerId, e.getMessage());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var containerId = runtimeOrchestrator.startContainer(new StartContainerRequest(
|
||||
deployment.getImageRef(),
|
||||
containerName,
|
||||
runtimeConfig.getDockerNetwork(),
|
||||
Map.of(
|
||||
"CAMELEER_AUTH_TOKEN", env.getBootstrapToken(),
|
||||
"CAMELEER_EXPORT_TYPE", "HTTP",
|
||||
"CAMELEER_EXPORT_ENDPOINT", runtimeConfig.getCameleer3ServerEndpoint(),
|
||||
"CAMELEER_APPLICATION_ID", app.getSlug(),
|
||||
"CAMELEER_ENVIRONMENT_ID", env.getSlug(),
|
||||
"CAMELEER_DISPLAY_NAME", containerName
|
||||
),
|
||||
runtimeConfig.parseMemoryLimitBytes(),
|
||||
runtimeConfig.getContainerCpuShares(),
|
||||
runtimeConfig.getAgentHealthPort()
|
||||
));
|
||||
|
||||
deployment.setOrchestratorMetadata(Map.of("containerId", containerId));
|
||||
deploymentRepository.save(deployment);
|
||||
|
||||
boolean healthy = waitForHealthy(containerId, runtimeConfig.getHealthCheckTimeout());
|
||||
|
||||
var previousDeploymentId = app.getCurrentDeploymentId();
|
||||
|
||||
if (healthy) {
|
||||
deployment.setObservedStatus(ObservedStatus.RUNNING);
|
||||
deployment.setDeployedAt(Instant.now());
|
||||
deploymentRepository.save(deployment);
|
||||
|
||||
app.setPreviousDeploymentId(previousDeploymentId);
|
||||
app.setCurrentDeploymentId(deployment.getId());
|
||||
appRepository.save(app);
|
||||
} else {
|
||||
deployment.setObservedStatus(ObservedStatus.FAILED);
|
||||
deployment.setErrorMessage("Container did not become healthy within timeout");
|
||||
deploymentRepository.save(deployment);
|
||||
|
||||
app.setCurrentDeploymentId(deployment.getId());
|
||||
appRepository.save(app);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Deployment {} failed: {}", deploymentId, e.getMessage(), e);
|
||||
deployment.setObservedStatus(ObservedStatus.FAILED);
|
||||
deployment.setErrorMessage(e.getMessage());
|
||||
deploymentRepository.save(deployment);
|
||||
}
|
||||
}
|
||||
|
||||
public DeploymentEntity stop(UUID appId, UUID actorId) {
|
||||
var app = appRepository.findById(appId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("App not found: " + appId));
|
||||
|
||||
if (app.getCurrentDeploymentId() == null) {
|
||||
throw new IllegalStateException("App has no active deployment: " + appId);
|
||||
}
|
||||
|
||||
var deployment = deploymentRepository.findById(app.getCurrentDeploymentId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Deployment not found: " + app.getCurrentDeploymentId()));
|
||||
|
||||
var metadata = deployment.getOrchestratorMetadata();
|
||||
if (metadata != null && metadata.containsKey("containerId")) {
|
||||
var containerId = (String) metadata.get("containerId");
|
||||
runtimeOrchestrator.stopContainer(containerId);
|
||||
}
|
||||
|
||||
deployment.setDesiredStatus(DesiredStatus.STOPPED);
|
||||
deployment.setObservedStatus(ObservedStatus.STOPPED);
|
||||
deployment.setStoppedAt(Instant.now());
|
||||
var saved = deploymentRepository.save(deployment);
|
||||
|
||||
var env = environmentRepository.findById(app.getEnvironmentId()).orElse(null);
|
||||
var tenantId = env != null ? env.getTenantId() : null;
|
||||
|
||||
auditService.log(actorId, null, tenantId,
|
||||
AuditAction.APP_STOP, app.getSlug(),
|
||||
env != null ? env.getSlug() : null, null, "SUCCESS", null);
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
public DeploymentEntity restart(UUID appId, UUID actorId) {
|
||||
stop(appId, actorId);
|
||||
return deploy(appId, actorId);
|
||||
}
|
||||
|
||||
public List<DeploymentEntity> listByAppId(UUID appId) {
|
||||
return deploymentRepository.findByAppIdOrderByVersionDesc(appId);
|
||||
}
|
||||
|
||||
public Optional<DeploymentEntity> getById(UUID deploymentId) {
|
||||
return deploymentRepository.findById(deploymentId);
|
||||
}
|
||||
|
||||
boolean waitForHealthy(String containerId, int timeoutSeconds) {
|
||||
var deadline = System.currentTimeMillis() + (timeoutSeconds * 1000L);
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
var status = runtimeOrchestrator.getContainerStatus(containerId);
|
||||
if (!status.running()) {
|
||||
return false;
|
||||
}
|
||||
if ("healthy".equals(status.state())) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
Thread.sleep(2000);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package net.siegeln.cameleer.saas.deployment;
|
||||
|
||||
public enum DesiredStatus {
|
||||
RUNNING, STOPPED
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package net.siegeln.cameleer.saas.deployment;
|
||||
|
||||
public enum ObservedStatus {
|
||||
BUILDING, STARTING, RUNNING, FAILED, STOPPED
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package net.siegeln.cameleer.saas.deployment.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
public record DeploymentResponse(
|
||||
UUID id, UUID appId, int version, String imageRef,
|
||||
String desiredStatus, String observedStatus, String errorMessage,
|
||||
Map<String, Object> orchestratorMetadata,
|
||||
Instant deployedAt, Instant stoppedAt, Instant createdAt
|
||||
) {}
|
||||
@@ -0,0 +1,117 @@
|
||||
package net.siegeln.cameleer.saas.environment;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import net.siegeln.cameleer.saas.environment.dto.CreateEnvironmentRequest;
|
||||
import net.siegeln.cameleer.saas.environment.dto.EnvironmentResponse;
|
||||
import net.siegeln.cameleer.saas.environment.dto.UpdateEnvironmentRequest;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/tenants/{tenantId}/environments")
|
||||
public class EnvironmentController {
|
||||
|
||||
private final EnvironmentService environmentService;
|
||||
|
||||
public EnvironmentController(EnvironmentService environmentService) {
|
||||
this.environmentService = environmentService;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<EnvironmentResponse> create(
|
||||
@PathVariable UUID tenantId,
|
||||
@Valid @RequestBody CreateEnvironmentRequest request,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
UUID actorId = resolveActorId(authentication);
|
||||
var entity = environmentService.create(tenantId, request.slug(), request.displayName(), actorId);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(entity));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<EnvironmentResponse>> list(@PathVariable UUID tenantId) {
|
||||
var environments = environmentService.listByTenantId(tenantId)
|
||||
.stream()
|
||||
.map(this::toResponse)
|
||||
.toList();
|
||||
return ResponseEntity.ok(environments);
|
||||
}
|
||||
|
||||
@GetMapping("/{environmentId}")
|
||||
public ResponseEntity<EnvironmentResponse> getById(
|
||||
@PathVariable UUID tenantId,
|
||||
@PathVariable UUID environmentId) {
|
||||
return environmentService.getById(environmentId)
|
||||
.map(entity -> ResponseEntity.ok(toResponse(entity)))
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@PatchMapping("/{environmentId}")
|
||||
public ResponseEntity<EnvironmentResponse> update(
|
||||
@PathVariable UUID tenantId,
|
||||
@PathVariable UUID environmentId,
|
||||
@Valid @RequestBody UpdateEnvironmentRequest request,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
UUID actorId = resolveActorId(authentication);
|
||||
var entity = environmentService.updateDisplayName(environmentId, request.displayName(), actorId);
|
||||
return ResponseEntity.ok(toResponse(entity));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/{environmentId}")
|
||||
public ResponseEntity<Void> delete(
|
||||
@PathVariable UUID tenantId,
|
||||
@PathVariable UUID environmentId,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
UUID actorId = resolveActorId(authentication);
|
||||
environmentService.delete(environmentId, actorId);
|
||||
return ResponseEntity.noContent().build();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.notFound().build();
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
}
|
||||
|
||||
private UUID resolveActorId(Authentication authentication) {
|
||||
String sub = authentication.getName();
|
||||
try {
|
||||
return UUID.fromString(sub);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return UUID.nameUUIDFromBytes(sub.getBytes());
|
||||
}
|
||||
}
|
||||
|
||||
private EnvironmentResponse toResponse(EnvironmentEntity entity) {
|
||||
return new EnvironmentResponse(
|
||||
entity.getId(),
|
||||
entity.getTenantId(),
|
||||
entity.getSlug(),
|
||||
entity.getDisplayName(),
|
||||
entity.getStatus().name(),
|
||||
entity.getCreatedAt(),
|
||||
entity.getUpdatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package net.siegeln.cameleer.saas.environment;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "environments")
|
||||
public class EnvironmentEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "tenant_id", nullable = false)
|
||||
private UUID tenantId;
|
||||
|
||||
@Column(nullable = false, length = 100)
|
||||
private String slug;
|
||||
|
||||
@Column(name = "display_name", nullable = false)
|
||||
private String displayName;
|
||||
|
||||
@Column(name = "bootstrap_token", nullable = false, columnDefinition = "TEXT")
|
||||
private String bootstrapToken;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
private EnvironmentStatus status = EnvironmentStatus.ACTIVE;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = Instant.now();
|
||||
updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public void setId(UUID id) { this.id = id; }
|
||||
public UUID getTenantId() { return tenantId; }
|
||||
public void setTenantId(UUID tenantId) { this.tenantId = tenantId; }
|
||||
public String getSlug() { return slug; }
|
||||
public void setSlug(String slug) { this.slug = slug; }
|
||||
public String getDisplayName() { return displayName; }
|
||||
public void setDisplayName(String displayName) { this.displayName = displayName; }
|
||||
public String getBootstrapToken() { return bootstrapToken; }
|
||||
public void setBootstrapToken(String bootstrapToken) { this.bootstrapToken = bootstrapToken; }
|
||||
public EnvironmentStatus getStatus() { return status; }
|
||||
public void setStatus(EnvironmentStatus status) { this.status = status; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
public Instant getUpdatedAt() { return updatedAt; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package net.siegeln.cameleer.saas.environment;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface EnvironmentRepository extends JpaRepository<EnvironmentEntity, UUID> {
|
||||
|
||||
List<EnvironmentEntity> findByTenantId(UUID tenantId);
|
||||
|
||||
Optional<EnvironmentEntity> findByTenantIdAndSlug(UUID tenantId, String slug);
|
||||
|
||||
long countByTenantId(UUID tenantId);
|
||||
|
||||
boolean existsByTenantIdAndSlug(UUID tenantId, String slug);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package net.siegeln.cameleer.saas.environment;
|
||||
|
||||
import net.siegeln.cameleer.saas.audit.AuditAction;
|
||||
import net.siegeln.cameleer.saas.audit.AuditService;
|
||||
import net.siegeln.cameleer.saas.license.LicenseDefaults;
|
||||
import net.siegeln.cameleer.saas.license.LicenseRepository;
|
||||
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
|
||||
import net.siegeln.cameleer.saas.tenant.Tier;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class EnvironmentService {
|
||||
|
||||
private final EnvironmentRepository environmentRepository;
|
||||
private final LicenseRepository licenseRepository;
|
||||
private final AuditService auditService;
|
||||
private final RuntimeConfig runtimeConfig;
|
||||
|
||||
public EnvironmentService(EnvironmentRepository environmentRepository,
|
||||
LicenseRepository licenseRepository,
|
||||
AuditService auditService,
|
||||
RuntimeConfig runtimeConfig) {
|
||||
this.environmentRepository = environmentRepository;
|
||||
this.licenseRepository = licenseRepository;
|
||||
this.auditService = auditService;
|
||||
this.runtimeConfig = runtimeConfig;
|
||||
}
|
||||
|
||||
public EnvironmentEntity create(UUID tenantId, String slug, String displayName, UUID actorId) {
|
||||
if (environmentRepository.existsByTenantIdAndSlug(tenantId, slug)) {
|
||||
throw new IllegalArgumentException("Slug already exists for this tenant: " + slug);
|
||||
}
|
||||
|
||||
enforceTierLimit(tenantId);
|
||||
|
||||
var entity = new EnvironmentEntity();
|
||||
entity.setTenantId(tenantId);
|
||||
entity.setSlug(slug);
|
||||
entity.setDisplayName(displayName);
|
||||
entity.setBootstrapToken(runtimeConfig.getBootstrapToken());
|
||||
|
||||
var saved = environmentRepository.save(entity);
|
||||
|
||||
auditService.log(actorId, null, tenantId,
|
||||
AuditAction.ENVIRONMENT_CREATE, slug,
|
||||
null, null, "SUCCESS", null);
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
public EnvironmentEntity createDefaultForTenant(UUID tenantId) {
|
||||
return environmentRepository.findByTenantIdAndSlug(tenantId, "default")
|
||||
.orElseGet(() -> create(tenantId, "default", "Default", null));
|
||||
}
|
||||
|
||||
public List<EnvironmentEntity> listByTenantId(UUID tenantId) {
|
||||
return environmentRepository.findByTenantId(tenantId);
|
||||
}
|
||||
|
||||
public Optional<EnvironmentEntity> getById(UUID id) {
|
||||
return environmentRepository.findById(id);
|
||||
}
|
||||
|
||||
public EnvironmentEntity updateDisplayName(UUID environmentId, String displayName, UUID actorId) {
|
||||
var entity = environmentRepository.findById(environmentId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Environment not found: " + environmentId));
|
||||
|
||||
entity.setDisplayName(displayName);
|
||||
var saved = environmentRepository.save(entity);
|
||||
|
||||
auditService.log(actorId, null, entity.getTenantId(),
|
||||
AuditAction.ENVIRONMENT_UPDATE, entity.getSlug(),
|
||||
null, null, "SUCCESS", null);
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
public void delete(UUID environmentId, UUID actorId) {
|
||||
var entity = environmentRepository.findById(environmentId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Environment not found: " + environmentId));
|
||||
|
||||
if ("default".equals(entity.getSlug())) {
|
||||
throw new IllegalStateException("Cannot delete the default environment");
|
||||
}
|
||||
|
||||
environmentRepository.delete(entity);
|
||||
|
||||
auditService.log(actorId, null, entity.getTenantId(),
|
||||
AuditAction.ENVIRONMENT_DELETE, entity.getSlug(),
|
||||
null, null, "SUCCESS", null);
|
||||
}
|
||||
|
||||
private void enforceTierLimit(UUID tenantId) {
|
||||
var license = licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId);
|
||||
if (license.isEmpty()) {
|
||||
throw new IllegalStateException("No active license");
|
||||
}
|
||||
var limits = LicenseDefaults.limitsForTier(Tier.valueOf(license.get().getTier()));
|
||||
var maxEnvs = (int) limits.getOrDefault("max_environments", 1);
|
||||
var currentCount = environmentRepository.countByTenantId(tenantId);
|
||||
if (maxEnvs != -1 && currentCount >= maxEnvs) {
|
||||
throw new IllegalStateException("Environment limit reached for current tier");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package net.siegeln.cameleer.saas.environment;
|
||||
|
||||
public enum EnvironmentStatus {
|
||||
ACTIVE, SUSPENDED
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package net.siegeln.cameleer.saas.environment.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record CreateEnvironmentRequest(
|
||||
@NotBlank @Size(min = 2, max = 100)
|
||||
@Pattern(regexp = "^[a-z0-9][a-z0-9-]*[a-z0-9]$", message = "Slug must be lowercase alphanumeric with hyphens")
|
||||
String slug,
|
||||
@NotBlank @Size(max = 255)
|
||||
String displayName
|
||||
) {}
|
||||
@@ -0,0 +1,14 @@
|
||||
package net.siegeln.cameleer.saas.environment.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public record EnvironmentResponse(
|
||||
UUID id,
|
||||
UUID tenantId,
|
||||
String slug,
|
||||
String displayName,
|
||||
String status,
|
||||
Instant createdAt,
|
||||
Instant updatedAt
|
||||
) {}
|
||||
@@ -0,0 +1,9 @@
|
||||
package net.siegeln.cameleer.saas.environment.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record UpdateEnvironmentRequest(
|
||||
@NotBlank @Size(max = 255)
|
||||
String displayName
|
||||
) {}
|
||||
@@ -0,0 +1,23 @@
|
||||
package net.siegeln.cameleer.saas.log;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
|
||||
import com.clickhouse.jdbc.ClickHouseDataSource;
|
||||
import java.util.Properties;
|
||||
|
||||
@Configuration
|
||||
@Profile("!test")
|
||||
public class ClickHouseConfig {
|
||||
|
||||
@Value("${cameleer.clickhouse.url:jdbc:clickhouse://clickhouse:8123/cameleer}")
|
||||
private String url;
|
||||
|
||||
@Bean(name = "clickHouseDataSource")
|
||||
public ClickHouseDataSource clickHouseDataSource() throws Exception {
|
||||
var properties = new Properties();
|
||||
return new ClickHouseDataSource(url, properties);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package net.siegeln.cameleer.saas.log;
|
||||
|
||||
import net.siegeln.cameleer.saas.log.dto.LogEntry;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
|
||||
@Service
|
||||
public class ContainerLogService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ContainerLogService.class);
|
||||
private static final int FLUSH_THRESHOLD = 100;
|
||||
|
||||
private final DataSource clickHouseDataSource;
|
||||
private final ConcurrentLinkedQueue<Object[]> buffer = new ConcurrentLinkedQueue<>();
|
||||
|
||||
@Autowired
|
||||
public ContainerLogService(
|
||||
@Autowired(required = false) @Qualifier("clickHouseDataSource") DataSource clickHouseDataSource) {
|
||||
this.clickHouseDataSource = clickHouseDataSource;
|
||||
if (clickHouseDataSource == null) {
|
||||
log.warn("ClickHouse data source not available — ContainerLogService running in no-op mode");
|
||||
} else {
|
||||
initSchema();
|
||||
}
|
||||
}
|
||||
|
||||
void initSchema() {
|
||||
if (clickHouseDataSource == null) return;
|
||||
try (var conn = clickHouseDataSource.getConnection();
|
||||
var stmt = conn.createStatement()) {
|
||||
stmt.execute("""
|
||||
CREATE TABLE IF NOT EXISTS container_logs (
|
||||
tenant_id UUID,
|
||||
environment_id UUID,
|
||||
app_id UUID,
|
||||
deployment_id UUID,
|
||||
timestamp DateTime64(3),
|
||||
stream String,
|
||||
message String
|
||||
) ENGINE = MergeTree()
|
||||
ORDER BY (tenant_id, environment_id, app_id, timestamp)
|
||||
""");
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to initialize ClickHouse schema", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void write(UUID tenantId, UUID envId, UUID appId, UUID deploymentId,
|
||||
String stream, String message, long timestampMillis) {
|
||||
if (clickHouseDataSource == null) return;
|
||||
buffer.add(new Object[]{tenantId, envId, appId, deploymentId, timestampMillis, stream, message});
|
||||
if (buffer.size() >= FLUSH_THRESHOLD) {
|
||||
flush();
|
||||
}
|
||||
}
|
||||
|
||||
public void flush() {
|
||||
if (clickHouseDataSource == null || buffer.isEmpty()) return;
|
||||
List<Object[]> batch = new ArrayList<>(FLUSH_THRESHOLD);
|
||||
Object[] row;
|
||||
while ((row = buffer.poll()) != null) {
|
||||
batch.add(row);
|
||||
}
|
||||
if (batch.isEmpty()) return;
|
||||
String sql = "INSERT INTO container_logs (tenant_id, environment_id, app_id, deployment_id, timestamp, stream, message) VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||
try (var conn = clickHouseDataSource.getConnection();
|
||||
var ps = conn.prepareStatement(sql)) {
|
||||
for (Object[] entry : batch) {
|
||||
ps.setObject(1, entry[0]); // tenant_id
|
||||
ps.setObject(2, entry[1]); // environment_id
|
||||
ps.setObject(3, entry[2]); // app_id
|
||||
ps.setObject(4, entry[3]); // deployment_id
|
||||
ps.setTimestamp(5, new Timestamp((Long) entry[4]));
|
||||
ps.setString(6, (String) entry[5]);
|
||||
ps.setString(7, (String) entry[6]);
|
||||
ps.addBatch();
|
||||
}
|
||||
ps.executeBatch();
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to flush log batch to ClickHouse ({} entries)", batch.size(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public List<LogEntry> query(UUID appId, Instant since, Instant until, int limit, String stream) {
|
||||
if (clickHouseDataSource == null) return List.of();
|
||||
StringBuilder sql = new StringBuilder(
|
||||
"SELECT app_id, deployment_id, timestamp, stream, message FROM container_logs WHERE app_id = ?");
|
||||
List<Object> params = new ArrayList<>();
|
||||
params.add(appId);
|
||||
if (since != null) {
|
||||
sql.append(" AND timestamp >= ?");
|
||||
params.add(Timestamp.from(since));
|
||||
}
|
||||
if (until != null) {
|
||||
sql.append(" AND timestamp <= ?");
|
||||
params.add(Timestamp.from(until));
|
||||
}
|
||||
if (stream != null && !"both".equalsIgnoreCase(stream)) {
|
||||
sql.append(" AND stream = ?");
|
||||
params.add(stream);
|
||||
}
|
||||
sql.append(" ORDER BY timestamp LIMIT ?");
|
||||
params.add(limit);
|
||||
List<LogEntry> results = new ArrayList<>();
|
||||
try (var conn = clickHouseDataSource.getConnection();
|
||||
var ps = conn.prepareStatement(sql.toString())) {
|
||||
for (int i = 0; i < params.size(); i++) {
|
||||
ps.setObject(i + 1, params.get(i));
|
||||
}
|
||||
try (var rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
results.add(new LogEntry(
|
||||
UUID.fromString(rs.getString("app_id")),
|
||||
UUID.fromString(rs.getString("deployment_id")),
|
||||
rs.getTimestamp("timestamp").toInstant(),
|
||||
rs.getString("stream"),
|
||||
rs.getString("message")
|
||||
));
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to query container logs for appId={}", appId, e);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package net.siegeln.cameleer.saas.log;
|
||||
|
||||
import net.siegeln.cameleer.saas.log.dto.LogEntry;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/apps/{appId}/logs")
|
||||
public class LogController {
|
||||
|
||||
private final ContainerLogService containerLogService;
|
||||
|
||||
public LogController(ContainerLogService containerLogService) {
|
||||
this.containerLogService = containerLogService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<LogEntry>> query(
|
||||
@PathVariable UUID appId,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant since,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant until,
|
||||
@RequestParam(defaultValue = "500") int limit,
|
||||
@RequestParam(defaultValue = "both") String stream) {
|
||||
List<LogEntry> entries = containerLogService.query(appId, since, until, limit, stream);
|
||||
return ResponseEntity.ok(entries);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package net.siegeln.cameleer.saas.log.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public record LogEntry(
|
||||
UUID appId, UUID deploymentId, Instant timestamp, String stream, String message
|
||||
) {}
|
||||
@@ -0,0 +1,9 @@
|
||||
package net.siegeln.cameleer.saas.runtime;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
public record BuildImageRequest(
|
||||
String baseImage,
|
||||
Path jarPath,
|
||||
String imageTag
|
||||
) {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package net.siegeln.cameleer.saas.runtime;
|
||||
|
||||
public record ContainerStatus(
|
||||
String state,
|
||||
boolean running,
|
||||
int exitCode,
|
||||
String error
|
||||
) {}
|
||||
@@ -0,0 +1,167 @@
|
||||
package net.siegeln.cameleer.saas.runtime;
|
||||
|
||||
import com.github.dockerjava.api.DockerClient;
|
||||
import com.github.dockerjava.api.async.ResultCallback;
|
||||
import com.github.dockerjava.api.command.BuildImageResultCallback;
|
||||
import com.github.dockerjava.api.model.*;
|
||||
import com.github.dockerjava.core.DefaultDockerClientConfig;
|
||||
import com.github.dockerjava.core.DockerClientImpl;
|
||||
import com.github.dockerjava.httpclient5.ApacheDockerHttpClient;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Component
|
||||
public class DockerRuntimeOrchestrator implements RuntimeOrchestrator {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DockerRuntimeOrchestrator.class);
|
||||
private DockerClient dockerClient;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
var config = DefaultDockerClientConfig.createDefaultConfigBuilder().build();
|
||||
var httpClient = new ApacheDockerHttpClient.Builder()
|
||||
.dockerHost(config.getDockerHost())
|
||||
.build();
|
||||
dockerClient = DockerClientImpl.getInstance(config, httpClient);
|
||||
log.info("Docker client initialized, host: {}", config.getDockerHost());
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void close() throws IOException {
|
||||
if (dockerClient != null) {
|
||||
dockerClient.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String buildImage(BuildImageRequest request) {
|
||||
Path buildDir = null;
|
||||
try {
|
||||
buildDir = Files.createTempDirectory("cameleer-build-");
|
||||
var dockerfile = buildDir.resolve("Dockerfile");
|
||||
Files.writeString(dockerfile,
|
||||
"FROM " + request.baseImage() + "\nCOPY app.jar /app/app.jar\n");
|
||||
Files.copy(request.jarPath(), buildDir.resolve("app.jar"), StandardCopyOption.REPLACE_EXISTING);
|
||||
|
||||
var imageId = dockerClient.buildImageCmd(buildDir.toFile())
|
||||
.withTags(Set.of(request.imageTag()))
|
||||
.exec(new BuildImageResultCallback())
|
||||
.awaitImageId();
|
||||
|
||||
log.info("Built image {} -> {}", request.imageTag(), imageId);
|
||||
return imageId;
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to build image: " + e.getMessage(), e);
|
||||
} finally {
|
||||
if (buildDir != null) {
|
||||
deleteDirectory(buildDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String startContainer(StartContainerRequest request) {
|
||||
var envList = request.envVars().entrySet().stream()
|
||||
.map(e -> e.getKey() + "=" + e.getValue())
|
||||
.toList();
|
||||
|
||||
var hostConfig = HostConfig.newHostConfig()
|
||||
.withMemory(request.memoryLimitBytes())
|
||||
.withMemorySwap(request.memoryLimitBytes())
|
||||
.withCpuShares(request.cpuShares())
|
||||
.withNetworkMode(request.network());
|
||||
|
||||
var container = dockerClient.createContainerCmd(request.imageRef())
|
||||
.withName(request.containerName())
|
||||
.withEnv(envList)
|
||||
.withHostConfig(hostConfig)
|
||||
.withHealthcheck(new HealthCheck()
|
||||
.withTest(List.of("CMD-SHELL",
|
||||
"wget -qO- http://localhost:" + request.healthCheckPort() + "/health || exit 1"))
|
||||
.withInterval(10_000_000_000L) // 10s
|
||||
.withTimeout(5_000_000_000L) // 5s
|
||||
.withRetries(3)
|
||||
.withStartPeriod(30_000_000_000L)) // 30s
|
||||
.exec();
|
||||
|
||||
dockerClient.startContainerCmd(container.getId()).exec();
|
||||
log.info("Started container {} ({})", request.containerName(), container.getId());
|
||||
return container.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stopContainer(String containerId) {
|
||||
try {
|
||||
dockerClient.stopContainerCmd(containerId).withTimeout(30).exec();
|
||||
log.info("Stopped container {}", containerId);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to stop container {}: {}", containerId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeContainer(String containerId) {
|
||||
try {
|
||||
dockerClient.removeContainerCmd(containerId).withForce(true).exec();
|
||||
log.info("Removed container {}", containerId);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to remove container {}: {}", containerId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ContainerStatus getContainerStatus(String containerId) {
|
||||
try {
|
||||
var inspection = dockerClient.inspectContainerCmd(containerId).exec();
|
||||
var state = inspection.getState();
|
||||
return new ContainerStatus(
|
||||
state.getStatus(),
|
||||
Boolean.TRUE.equals(state.getRunning()),
|
||||
state.getExitCodeLong() != null ? state.getExitCodeLong().intValue() : 0,
|
||||
state.getError());
|
||||
} catch (Exception e) {
|
||||
return new ContainerStatus("not_found", false, -1, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void streamLogs(String containerId, LogConsumer consumer) {
|
||||
dockerClient.logContainerCmd(containerId)
|
||||
.withStdOut(true)
|
||||
.withStdErr(true)
|
||||
.withFollowStream(true)
|
||||
.withTimestamps(true)
|
||||
.exec(new ResultCallback.Adapter<Frame>() {
|
||||
@Override
|
||||
public void onNext(Frame frame) {
|
||||
var stream = frame.getStreamType() == StreamType.STDERR ? "stderr" : "stdout";
|
||||
consumer.accept(stream, new String(frame.getPayload()).trim(),
|
||||
System.currentTimeMillis());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void deleteDirectory(Path dir) {
|
||||
try {
|
||||
Files.walk(dir)
|
||||
.sorted(Comparator.reverseOrder())
|
||||
.map(Path::toFile)
|
||||
.forEach(File::delete);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to clean up build directory: {}", dir, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package net.siegeln.cameleer.saas.runtime;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface LogConsumer {
|
||||
void accept(String stream, String message, long timestampMillis);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package net.siegeln.cameleer.saas.runtime;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class RuntimeConfig {
|
||||
|
||||
@Value("${cameleer.runtime.max-jar-size:209715200}")
|
||||
private long maxJarSize;
|
||||
|
||||
@Value("${cameleer.runtime.jar-storage-path:/data/jars}")
|
||||
private String jarStoragePath;
|
||||
|
||||
@Value("${cameleer.runtime.base-image:cameleer-runtime-base:latest}")
|
||||
private String baseImage;
|
||||
|
||||
@Value("${cameleer.runtime.docker-network:cameleer}")
|
||||
private String dockerNetwork;
|
||||
|
||||
@Value("${cameleer.runtime.agent-health-port:9464}")
|
||||
private int agentHealthPort;
|
||||
|
||||
@Value("${cameleer.runtime.health-check-timeout:60}")
|
||||
private int healthCheckTimeout;
|
||||
|
||||
@Value("${cameleer.runtime.deployment-thread-pool-size:4}")
|
||||
private int deploymentThreadPoolSize;
|
||||
|
||||
@Value("${cameleer.runtime.container-memory-limit:512m}")
|
||||
private String containerMemoryLimit;
|
||||
|
||||
@Value("${cameleer.runtime.container-cpu-shares:512}")
|
||||
private int containerCpuShares;
|
||||
|
||||
@Value("${cameleer.runtime.bootstrap-token:${CAMELEER_AUTH_TOKEN:}}")
|
||||
private String bootstrapToken;
|
||||
|
||||
@Value("${cameleer.runtime.cameleer3-server-endpoint:http://cameleer3-server:8081}")
|
||||
private String cameleer3ServerEndpoint;
|
||||
|
||||
public long getMaxJarSize() { return maxJarSize; }
|
||||
public String getJarStoragePath() { return jarStoragePath; }
|
||||
public String getBaseImage() { return baseImage; }
|
||||
public String getDockerNetwork() { return dockerNetwork; }
|
||||
public int getAgentHealthPort() { return agentHealthPort; }
|
||||
public int getHealthCheckTimeout() { return healthCheckTimeout; }
|
||||
public int getDeploymentThreadPoolSize() { return deploymentThreadPoolSize; }
|
||||
public String getContainerMemoryLimit() { return containerMemoryLimit; }
|
||||
public int getContainerCpuShares() { return containerCpuShares; }
|
||||
public String getBootstrapToken() { return bootstrapToken; }
|
||||
public String getCameleer3ServerEndpoint() { return cameleer3ServerEndpoint; }
|
||||
|
||||
public long parseMemoryLimitBytes() {
|
||||
var limit = containerMemoryLimit.trim().toLowerCase();
|
||||
if (limit.endsWith("g")) {
|
||||
return Long.parseLong(limit.substring(0, limit.length() - 1)) * 1024 * 1024 * 1024;
|
||||
} else if (limit.endsWith("m")) {
|
||||
return Long.parseLong(limit.substring(0, limit.length() - 1)) * 1024 * 1024;
|
||||
}
|
||||
return Long.parseLong(limit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package net.siegeln.cameleer.saas.runtime;
|
||||
|
||||
public interface RuntimeOrchestrator {
|
||||
String buildImage(BuildImageRequest request);
|
||||
String startContainer(StartContainerRequest request);
|
||||
void stopContainer(String containerId);
|
||||
void removeContainer(String containerId);
|
||||
ContainerStatus getContainerStatus(String containerId);
|
||||
void streamLogs(String containerId, LogConsumer consumer);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package net.siegeln.cameleer.saas.runtime;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public record StartContainerRequest(
|
||||
String imageRef,
|
||||
String containerName,
|
||||
String network,
|
||||
Map<String, String> envVars,
|
||||
long memoryLimitBytes,
|
||||
int cpuShares,
|
||||
int healthCheckPort
|
||||
) {}
|
||||
@@ -2,6 +2,7 @@ package net.siegeln.cameleer.saas.tenant;
|
||||
|
||||
import net.siegeln.cameleer.saas.audit.AuditAction;
|
||||
import net.siegeln.cameleer.saas.audit.AuditService;
|
||||
import net.siegeln.cameleer.saas.environment.EnvironmentService;
|
||||
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
|
||||
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -16,11 +17,13 @@ public class TenantService {
|
||||
private final TenantRepository tenantRepository;
|
||||
private final AuditService auditService;
|
||||
private final LogtoManagementClient logtoClient;
|
||||
private final EnvironmentService environmentService;
|
||||
|
||||
public TenantService(TenantRepository tenantRepository, AuditService auditService, LogtoManagementClient logtoClient) {
|
||||
public TenantService(TenantRepository tenantRepository, AuditService auditService, LogtoManagementClient logtoClient, EnvironmentService environmentService) {
|
||||
this.tenantRepository = tenantRepository;
|
||||
this.auditService = auditService;
|
||||
this.logtoClient = logtoClient;
|
||||
this.environmentService = environmentService;
|
||||
}
|
||||
|
||||
public TenantEntity create(CreateTenantRequest request, UUID actorId) {
|
||||
@@ -44,6 +47,8 @@ public class TenantService {
|
||||
}
|
||||
}
|
||||
|
||||
environmentService.createDefaultForTenant(saved.getId());
|
||||
|
||||
auditService.log(actorId, null, saved.getId(),
|
||||
AuditAction.TENANT_CREATE, saved.getSlug(),
|
||||
null, null, "SUCCESS", null);
|
||||
|
||||
@@ -33,3 +33,17 @@ cameleer:
|
||||
logto-endpoint: ${LOGTO_ENDPOINT:}
|
||||
m2m-client-id: ${LOGTO_M2M_CLIENT_ID:}
|
||||
m2m-client-secret: ${LOGTO_M2M_CLIENT_SECRET:}
|
||||
runtime:
|
||||
max-jar-size: 209715200
|
||||
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
|
||||
deployment-thread-pool-size: 4
|
||||
container-memory-limit: ${CAMELEER_CONTAINER_MEMORY_LIMIT:512m}
|
||||
container-cpu-shares: ${CAMELEER_CONTAINER_CPU_SHARES:512}
|
||||
bootstrap-token: ${CAMELEER_AUTH_TOKEN:}
|
||||
cameleer3-server-endpoint: ${CAMELEER3_SERVER_ENDPOINT:http://cameleer3-server:8081}
|
||||
clickhouse:
|
||||
url: ${CLICKHOUSE_URL:jdbc:clickhouse://clickhouse:8123/cameleer}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE environments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
slug VARCHAR(100) NOT NULL,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
bootstrap_token TEXT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(tenant_id, slug)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_environments_tenant_id ON environments(tenant_id);
|
||||
17
src/main/resources/db/migration/V008__create_apps.sql
Normal file
17
src/main/resources/db/migration/V008__create_apps.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
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,
|
||||
jar_storage_path VARCHAR(500),
|
||||
jar_checksum VARCHAR(64),
|
||||
jar_original_filename VARCHAR(255),
|
||||
jar_size_bytes BIGINT,
|
||||
current_deployment_id UUID,
|
||||
previous_deployment_id UUID,
|
||||
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);
|
||||
16
src/main/resources/db/migration/V009__create_deployments.sql
Normal file
16
src/main/resources/db/migration/V009__create_deployments.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE deployments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL,
|
||||
image_ref VARCHAR(500) NOT NULL,
|
||||
desired_status VARCHAR(20) NOT NULL DEFAULT 'RUNNING',
|
||||
observed_status VARCHAR(20) NOT NULL DEFAULT 'BUILDING',
|
||||
orchestrator_metadata JSONB DEFAULT '{}',
|
||||
error_message TEXT,
|
||||
deployed_at TIMESTAMPTZ,
|
||||
stopped_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(app_id, version)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_deployments_app_id ON deployments(app_id);
|
||||
@@ -0,0 +1,169 @@
|
||||
package net.siegeln.cameleer.saas.app;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import net.siegeln.cameleer.saas.TestcontainersConfig;
|
||||
import net.siegeln.cameleer.saas.TestSecurityConfig;
|
||||
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
|
||||
import net.siegeln.cameleer.saas.license.LicenseDefaults;
|
||||
import net.siegeln.cameleer.saas.license.LicenseEntity;
|
||||
import net.siegeln.cameleer.saas.license.LicenseRepository;
|
||||
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
||||
import net.siegeln.cameleer.saas.tenant.TenantRepository;
|
||||
import net.siegeln.cameleer.saas.tenant.Tier;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Import({TestcontainersConfig.class, TestSecurityConfig.class})
|
||||
@ActiveProfiles("test")
|
||||
class AppControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Autowired
|
||||
private AppRepository appRepository;
|
||||
|
||||
@Autowired
|
||||
private EnvironmentRepository environmentRepository;
|
||||
|
||||
@Autowired
|
||||
private LicenseRepository licenseRepository;
|
||||
|
||||
@Autowired
|
||||
private TenantRepository tenantRepository;
|
||||
|
||||
private UUID environmentId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
appRepository.deleteAll();
|
||||
environmentRepository.deleteAll();
|
||||
licenseRepository.deleteAll();
|
||||
tenantRepository.deleteAll();
|
||||
|
||||
var tenant = new TenantEntity();
|
||||
tenant.setName("Test Org");
|
||||
tenant.setSlug("test-org-" + System.nanoTime());
|
||||
var savedTenant = tenantRepository.save(tenant);
|
||||
var tenantId = savedTenant.getId();
|
||||
|
||||
var license = new LicenseEntity();
|
||||
license.setTenantId(tenantId);
|
||||
license.setTier("MID");
|
||||
license.setFeatures(LicenseDefaults.featuresForTier(Tier.MID));
|
||||
license.setLimits(LicenseDefaults.limitsForTier(Tier.MID));
|
||||
license.setExpiresAt(Instant.now().plus(365, ChronoUnit.DAYS));
|
||||
license.setToken("test-token");
|
||||
licenseRepository.save(license);
|
||||
|
||||
var env = new net.siegeln.cameleer.saas.environment.EnvironmentEntity();
|
||||
env.setTenantId(tenantId);
|
||||
env.setSlug("default");
|
||||
env.setDisplayName("Default");
|
||||
env.setBootstrapToken("test-bootstrap-token");
|
||||
var savedEnv = environmentRepository.save(env);
|
||||
environmentId = savedEnv.getId();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createApp_shouldReturn201() throws Exception {
|
||||
var metadata = new MockMultipartFile("metadata", "", "application/json",
|
||||
"""
|
||||
{"slug": "order-svc", "displayName": "Order Service"}
|
||||
""".getBytes());
|
||||
var jar = new MockMultipartFile("file", "order-service.jar",
|
||||
"application/java-archive", "fake-jar".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps")
|
||||
.file(jar)
|
||||
.file(metadata)
|
||||
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.slug").value("order-svc"))
|
||||
.andExpect(jsonPath("$.displayName").value("Order Service"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createApp_nonJarFile_shouldReturn400() throws Exception {
|
||||
var metadata = new MockMultipartFile("metadata", "", "application/json",
|
||||
"""
|
||||
{"slug": "order-svc", "displayName": "Order Service"}
|
||||
""".getBytes());
|
||||
var txt = new MockMultipartFile("file", "readme.txt",
|
||||
"text/plain", "hello".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps")
|
||||
.file(txt)
|
||||
.file(metadata)
|
||||
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listApps_shouldReturnAll() throws Exception {
|
||||
var metadata = new MockMultipartFile("metadata", "", "application/json",
|
||||
"""
|
||||
{"slug": "billing-svc", "displayName": "Billing Service"}
|
||||
""".getBytes());
|
||||
var jar = new MockMultipartFile("file", "billing-service.jar",
|
||||
"application/java-archive", "fake-jar".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps")
|
||||
.file(jar)
|
||||
.file(metadata)
|
||||
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
|
||||
.andExpect(status().isCreated());
|
||||
|
||||
mockMvc.perform(get("/api/environments/" + environmentId + "/apps")
|
||||
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].slug").value("billing-svc"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteApp_shouldReturn204() throws Exception {
|
||||
var metadata = new MockMultipartFile("metadata", "", "application/json",
|
||||
"""
|
||||
{"slug": "payment-svc", "displayName": "Payment Service"}
|
||||
""".getBytes());
|
||||
var jar = new MockMultipartFile("file", "payment-service.jar",
|
||||
"application/java-archive", "fake-jar".getBytes());
|
||||
|
||||
var createResult = mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps")
|
||||
.file(jar)
|
||||
.file(metadata)
|
||||
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn();
|
||||
|
||||
String appId = objectMapper.readTree(createResult.getResponse().getContentAsString())
|
||||
.get("id").asText();
|
||||
|
||||
mockMvc.perform(delete("/api/environments/" + environmentId + "/apps/" + appId)
|
||||
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
}
|
||||
167
src/test/java/net/siegeln/cameleer/saas/app/AppServiceTest.java
Normal file
167
src/test/java/net/siegeln/cameleer/saas/app/AppServiceTest.java
Normal file
@@ -0,0 +1,167 @@
|
||||
package net.siegeln.cameleer.saas.app;
|
||||
|
||||
import net.siegeln.cameleer.saas.audit.AuditAction;
|
||||
import net.siegeln.cameleer.saas.audit.AuditService;
|
||||
import net.siegeln.cameleer.saas.environment.EnvironmentEntity;
|
||||
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
|
||||
import net.siegeln.cameleer.saas.license.LicenseEntity;
|
||||
import net.siegeln.cameleer.saas.license.LicenseRepository;
|
||||
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class AppServiceTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Mock
|
||||
private AppRepository appRepository;
|
||||
|
||||
@Mock
|
||||
private EnvironmentRepository environmentRepository;
|
||||
|
||||
@Mock
|
||||
private LicenseRepository licenseRepository;
|
||||
|
||||
@Mock
|
||||
private AuditService auditService;
|
||||
|
||||
@Mock
|
||||
private RuntimeConfig runtimeConfig;
|
||||
|
||||
private AppService appService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
when(runtimeConfig.getJarStoragePath()).thenReturn(tempDir.toString());
|
||||
when(runtimeConfig.getMaxJarSize()).thenReturn(209715200L);
|
||||
appService = new AppService(appRepository, environmentRepository, licenseRepository, auditService, runtimeConfig);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_shouldStoreJarAndCreateApp() throws Exception {
|
||||
var envId = UUID.randomUUID();
|
||||
var tenantId = UUID.randomUUID();
|
||||
var actorId = UUID.randomUUID();
|
||||
|
||||
var env = new EnvironmentEntity();
|
||||
env.setId(envId);
|
||||
env.setTenantId(tenantId);
|
||||
env.setSlug("default");
|
||||
|
||||
var license = new LicenseEntity();
|
||||
license.setTenantId(tenantId);
|
||||
license.setTier("MID");
|
||||
|
||||
var jarBytes = "fake-jar-content".getBytes();
|
||||
var jarFile = new MockMultipartFile("file", "myapp.jar", "application/java-archive", jarBytes);
|
||||
|
||||
when(environmentRepository.findById(envId)).thenReturn(Optional.of(env));
|
||||
when(appRepository.existsByEnvironmentIdAndSlug(envId, "myapp")).thenReturn(false);
|
||||
when(appRepository.countByTenantId(tenantId)).thenReturn(0L);
|
||||
when(licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId))
|
||||
.thenReturn(Optional.of(license));
|
||||
when(appRepository.save(any(AppEntity.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
var result = appService.create(envId, "myapp", "My App", jarFile, actorId);
|
||||
|
||||
assertThat(result.getSlug()).isEqualTo("myapp");
|
||||
assertThat(result.getDisplayName()).isEqualTo("My App");
|
||||
assertThat(result.getEnvironmentId()).isEqualTo(envId);
|
||||
assertThat(result.getJarOriginalFilename()).isEqualTo("myapp.jar");
|
||||
assertThat(result.getJarSizeBytes()).isEqualTo((long) jarBytes.length);
|
||||
assertThat(result.getJarChecksum()).isNotBlank();
|
||||
assertThat(result.getJarStoragePath()).contains("tenants")
|
||||
.contains("envs")
|
||||
.contains("apps")
|
||||
.endsWith("app.jar");
|
||||
|
||||
var actionCaptor = ArgumentCaptor.forClass(AuditAction.class);
|
||||
verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any());
|
||||
assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.APP_CREATE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_shouldRejectNonJarFile() {
|
||||
var envId = UUID.randomUUID();
|
||||
var actorId = UUID.randomUUID();
|
||||
|
||||
var textFile = new MockMultipartFile("file", "readme.txt", "text/plain", "hello".getBytes());
|
||||
|
||||
assertThatThrownBy(() -> appService.create(envId, "myapp", "My App", textFile, actorId))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining(".jar");
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_shouldRejectDuplicateSlug() {
|
||||
var envId = UUID.randomUUID();
|
||||
var tenantId = UUID.randomUUID();
|
||||
var actorId = UUID.randomUUID();
|
||||
|
||||
var env = new EnvironmentEntity();
|
||||
env.setId(envId);
|
||||
env.setTenantId(tenantId);
|
||||
env.setSlug("default");
|
||||
|
||||
var jarFile = new MockMultipartFile("file", "myapp.jar", "application/java-archive", "fake-jar".getBytes());
|
||||
|
||||
when(environmentRepository.findById(envId)).thenReturn(Optional.of(env));
|
||||
when(appRepository.existsByEnvironmentIdAndSlug(envId, "myapp")).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> appService.create(envId, "myapp", "My App", jarFile, actorId))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("myapp");
|
||||
}
|
||||
|
||||
@Test
|
||||
void reuploadJar_shouldUpdateChecksumAndPath() throws Exception {
|
||||
var appId = UUID.randomUUID();
|
||||
var envId = UUID.randomUUID();
|
||||
var actorId = UUID.randomUUID();
|
||||
|
||||
var existingApp = new AppEntity();
|
||||
existingApp.setId(appId);
|
||||
existingApp.setEnvironmentId(envId);
|
||||
existingApp.setSlug("myapp");
|
||||
existingApp.setDisplayName("My App");
|
||||
existingApp.setJarStoragePath("tenants/some-tenant/envs/default/apps/myapp/app.jar");
|
||||
existingApp.setJarChecksum("oldchecksum");
|
||||
existingApp.setJarOriginalFilename("old.jar");
|
||||
existingApp.setJarSizeBytes(100L);
|
||||
|
||||
var newJarBytes = "new-jar-content".getBytes();
|
||||
var newJarFile = new MockMultipartFile("file", "new-myapp.jar", "application/java-archive", newJarBytes);
|
||||
|
||||
when(appRepository.findById(appId)).thenReturn(Optional.of(existingApp));
|
||||
when(appRepository.save(any(AppEntity.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
var result = appService.reuploadJar(appId, newJarFile, actorId);
|
||||
|
||||
assertThat(result.getJarOriginalFilename()).isEqualTo("new-myapp.jar");
|
||||
assertThat(result.getJarSizeBytes()).isEqualTo((long) newJarBytes.length);
|
||||
assertThat(result.getJarChecksum()).isNotBlank();
|
||||
assertThat(result.getJarChecksum()).isNotEqualTo("oldchecksum");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package net.siegeln.cameleer.saas.deployment;
|
||||
|
||||
import net.siegeln.cameleer.saas.TestSecurityConfig;
|
||||
import net.siegeln.cameleer.saas.TestcontainersConfig;
|
||||
import net.siegeln.cameleer.saas.app.AppEntity;
|
||||
import net.siegeln.cameleer.saas.app.AppRepository;
|
||||
import net.siegeln.cameleer.saas.environment.EnvironmentEntity;
|
||||
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
|
||||
import net.siegeln.cameleer.saas.license.LicenseDefaults;
|
||||
import net.siegeln.cameleer.saas.license.LicenseEntity;
|
||||
import net.siegeln.cameleer.saas.license.LicenseRepository;
|
||||
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
||||
import net.siegeln.cameleer.saas.tenant.TenantRepository;
|
||||
import net.siegeln.cameleer.saas.tenant.Tier;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Import({TestcontainersConfig.class, TestSecurityConfig.class})
|
||||
@ActiveProfiles("test")
|
||||
class DeploymentControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private DeploymentRepository deploymentRepository;
|
||||
|
||||
@Autowired
|
||||
private AppRepository appRepository;
|
||||
|
||||
@Autowired
|
||||
private EnvironmentRepository environmentRepository;
|
||||
|
||||
@Autowired
|
||||
private LicenseRepository licenseRepository;
|
||||
|
||||
@Autowired
|
||||
private TenantRepository tenantRepository;
|
||||
|
||||
private UUID appId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
deploymentRepository.deleteAll();
|
||||
appRepository.deleteAll();
|
||||
environmentRepository.deleteAll();
|
||||
licenseRepository.deleteAll();
|
||||
tenantRepository.deleteAll();
|
||||
|
||||
var tenant = new TenantEntity();
|
||||
tenant.setName("Test Org");
|
||||
tenant.setSlug("test-org-" + System.nanoTime());
|
||||
var savedTenant = tenantRepository.save(tenant);
|
||||
var tenantId = savedTenant.getId();
|
||||
|
||||
var license = new LicenseEntity();
|
||||
license.setTenantId(tenantId);
|
||||
license.setTier("MID");
|
||||
license.setFeatures(LicenseDefaults.featuresForTier(Tier.MID));
|
||||
license.setLimits(LicenseDefaults.limitsForTier(Tier.MID));
|
||||
license.setExpiresAt(Instant.now().plus(365, ChronoUnit.DAYS));
|
||||
license.setToken("test-token");
|
||||
licenseRepository.save(license);
|
||||
|
||||
var env = new EnvironmentEntity();
|
||||
env.setTenantId(tenantId);
|
||||
env.setSlug("default");
|
||||
env.setDisplayName("Default");
|
||||
env.setBootstrapToken("test-bootstrap-token");
|
||||
var savedEnv = environmentRepository.save(env);
|
||||
|
||||
var app = new AppEntity();
|
||||
app.setEnvironmentId(savedEnv.getId());
|
||||
app.setSlug("test-app");
|
||||
app.setDisplayName("Test App");
|
||||
app.setJarStoragePath("tenants/test-org/envs/default/apps/test-app/app.jar");
|
||||
app.setJarChecksum("abc123def456");
|
||||
var savedApp = appRepository.save(app);
|
||||
appId = savedApp.getId();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listDeployments_shouldReturnEmpty() throws Exception {
|
||||
mockMvc.perform(get("/api/apps/" + appId + "/deployments")
|
||||
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").isArray())
|
||||
.andExpect(jsonPath("$.length()").value(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDeployment_notFound_shouldReturn404() throws Exception {
|
||||
mockMvc.perform(get("/api/apps/" + appId + "/deployments/" + UUID.randomUUID())
|
||||
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deploy_noAuth_shouldReturn401() throws Exception {
|
||||
mockMvc.perform(post("/api/apps/" + appId + "/deploy"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package net.siegeln.cameleer.saas.deployment;
|
||||
|
||||
import net.siegeln.cameleer.saas.app.AppEntity;
|
||||
import net.siegeln.cameleer.saas.app.AppRepository;
|
||||
import net.siegeln.cameleer.saas.app.AppService;
|
||||
import net.siegeln.cameleer.saas.audit.AuditAction;
|
||||
import net.siegeln.cameleer.saas.audit.AuditService;
|
||||
import net.siegeln.cameleer.saas.environment.EnvironmentEntity;
|
||||
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
|
||||
import net.siegeln.cameleer.saas.runtime.BuildImageRequest;
|
||||
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
|
||||
import net.siegeln.cameleer.saas.runtime.RuntimeOrchestrator;
|
||||
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
||||
import net.siegeln.cameleer.saas.tenant.TenantRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class DeploymentServiceTest {
|
||||
|
||||
@Mock
|
||||
private DeploymentRepository deploymentRepository;
|
||||
|
||||
@Mock
|
||||
private AppRepository appRepository;
|
||||
|
||||
@Mock
|
||||
private AppService appService;
|
||||
|
||||
@Mock
|
||||
private EnvironmentRepository environmentRepository;
|
||||
|
||||
@Mock
|
||||
private TenantRepository tenantRepository;
|
||||
|
||||
@Mock
|
||||
private RuntimeOrchestrator runtimeOrchestrator;
|
||||
|
||||
@Mock
|
||||
private RuntimeConfig runtimeConfig;
|
||||
|
||||
@Mock
|
||||
private AuditService auditService;
|
||||
|
||||
private DeploymentService deploymentService;
|
||||
|
||||
private UUID appId;
|
||||
private UUID envId;
|
||||
private UUID tenantId;
|
||||
private UUID actorId;
|
||||
private AppEntity app;
|
||||
private EnvironmentEntity env;
|
||||
private TenantEntity tenant;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
deploymentService = new DeploymentService(
|
||||
deploymentRepository,
|
||||
appRepository,
|
||||
appService,
|
||||
environmentRepository,
|
||||
tenantRepository,
|
||||
runtimeOrchestrator,
|
||||
runtimeConfig,
|
||||
auditService
|
||||
);
|
||||
|
||||
appId = UUID.randomUUID();
|
||||
envId = UUID.randomUUID();
|
||||
tenantId = UUID.randomUUID();
|
||||
actorId = UUID.randomUUID();
|
||||
|
||||
env = new EnvironmentEntity();
|
||||
env.setId(envId);
|
||||
env.setTenantId(tenantId);
|
||||
env.setSlug("prod");
|
||||
env.setBootstrapToken("tok-abc");
|
||||
|
||||
tenant = new TenantEntity();
|
||||
tenant.setSlug("acme");
|
||||
|
||||
app = new AppEntity();
|
||||
app.setId(appId);
|
||||
app.setEnvironmentId(envId);
|
||||
app.setSlug("myapp");
|
||||
app.setDisplayName("My App");
|
||||
app.setJarStoragePath("tenants/acme/envs/prod/apps/myapp/app.jar");
|
||||
|
||||
when(runtimeConfig.getBaseImage()).thenReturn("cameleer-runtime-base:latest");
|
||||
when(runtimeConfig.getDockerNetwork()).thenReturn("cameleer");
|
||||
when(runtimeConfig.getAgentHealthPort()).thenReturn(9464);
|
||||
when(runtimeConfig.getHealthCheckTimeout()).thenReturn(60);
|
||||
when(runtimeConfig.parseMemoryLimitBytes()).thenReturn(536870912L);
|
||||
when(runtimeConfig.getContainerCpuShares()).thenReturn(512);
|
||||
when(runtimeConfig.getCameleer3ServerEndpoint()).thenReturn("http://cameleer3-server:8081");
|
||||
|
||||
when(appRepository.findById(appId)).thenReturn(Optional.of(app));
|
||||
when(environmentRepository.findById(envId)).thenReturn(Optional.of(env));
|
||||
when(tenantRepository.findById(tenantId)).thenReturn(Optional.of(tenant));
|
||||
when(deploymentRepository.findMaxVersionByAppId(appId)).thenReturn(0);
|
||||
when(deploymentRepository.save(any(DeploymentEntity.class))).thenAnswer(inv -> {
|
||||
var d = (DeploymentEntity) inv.getArgument(0);
|
||||
if (d.getId() == null) {
|
||||
d.setId(UUID.randomUUID());
|
||||
}
|
||||
return d;
|
||||
});
|
||||
when(appService.resolveJarPath(any())).thenReturn(Path.of("/data/jars/tenants/acme/envs/prod/apps/myapp/app.jar"));
|
||||
when(runtimeOrchestrator.buildImage(any(BuildImageRequest.class))).thenReturn("sha256:abc123");
|
||||
when(runtimeOrchestrator.startContainer(any())).thenReturn("container-id-123");
|
||||
}
|
||||
|
||||
@Test
|
||||
void deploy_shouldCreateDeploymentWithBuildingStatus() {
|
||||
var result = deploymentService.deploy(appId, actorId);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.getAppId()).isEqualTo(appId);
|
||||
assertThat(result.getVersion()).isEqualTo(1);
|
||||
assertThat(result.getObservedStatus()).isEqualTo(ObservedStatus.BUILDING);
|
||||
assertThat(result.getImageRef()).contains("myapp").contains("v1");
|
||||
|
||||
var actionCaptor = ArgumentCaptor.forClass(AuditAction.class);
|
||||
verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any());
|
||||
assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.APP_DEPLOY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deploy_shouldRejectAppWithNoJar() {
|
||||
app.setJarStoragePath(null);
|
||||
|
||||
assertThatThrownBy(() -> deploymentService.deploy(appId, actorId))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("JAR");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stop_shouldUpdateDesiredStatus() {
|
||||
var deploymentId = UUID.randomUUID();
|
||||
app.setCurrentDeploymentId(deploymentId);
|
||||
|
||||
var deployment = new DeploymentEntity();
|
||||
deployment.setId(deploymentId);
|
||||
deployment.setAppId(appId);
|
||||
deployment.setVersion(1);
|
||||
deployment.setImageRef("cameleer-runtime-prod-myapp:v1");
|
||||
deployment.setObservedStatus(ObservedStatus.RUNNING);
|
||||
deployment.setOrchestratorMetadata(Map.of("containerId", "container-id-123"));
|
||||
|
||||
when(deploymentRepository.findById(deploymentId)).thenReturn(Optional.of(deployment));
|
||||
|
||||
var result = deploymentService.stop(appId, actorId);
|
||||
|
||||
assertThat(result.getDesiredStatus()).isEqualTo(DesiredStatus.STOPPED);
|
||||
assertThat(result.getObservedStatus()).isEqualTo(ObservedStatus.STOPPED);
|
||||
|
||||
var actionCaptor = ArgumentCaptor.forClass(AuditAction.class);
|
||||
verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any());
|
||||
assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.APP_STOP);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package net.siegeln.cameleer.saas.environment;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import net.siegeln.cameleer.saas.TestcontainersConfig;
|
||||
import net.siegeln.cameleer.saas.TestSecurityConfig;
|
||||
import net.siegeln.cameleer.saas.environment.dto.CreateEnvironmentRequest;
|
||||
import net.siegeln.cameleer.saas.environment.dto.UpdateEnvironmentRequest;
|
||||
import net.siegeln.cameleer.saas.license.LicenseDefaults;
|
||||
import net.siegeln.cameleer.saas.license.LicenseEntity;
|
||||
import net.siegeln.cameleer.saas.license.LicenseRepository;
|
||||
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
||||
import net.siegeln.cameleer.saas.tenant.TenantRepository;
|
||||
import net.siegeln.cameleer.saas.tenant.Tier;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Import({TestcontainersConfig.class, TestSecurityConfig.class})
|
||||
@ActiveProfiles("test")
|
||||
class EnvironmentControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Autowired
|
||||
private EnvironmentRepository environmentRepository;
|
||||
|
||||
@Autowired
|
||||
private LicenseRepository licenseRepository;
|
||||
|
||||
@Autowired
|
||||
private TenantRepository tenantRepository;
|
||||
|
||||
private UUID tenantId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
environmentRepository.deleteAll();
|
||||
licenseRepository.deleteAll();
|
||||
tenantRepository.deleteAll();
|
||||
|
||||
var tenant = new TenantEntity();
|
||||
tenant.setName("Test Org");
|
||||
tenant.setSlug("test-org-" + System.nanoTime());
|
||||
var savedTenant = tenantRepository.save(tenant);
|
||||
tenantId = savedTenant.getId();
|
||||
|
||||
var license = new LicenseEntity();
|
||||
license.setTenantId(tenantId);
|
||||
license.setTier("MID");
|
||||
license.setFeatures(LicenseDefaults.featuresForTier(Tier.MID));
|
||||
license.setLimits(LicenseDefaults.limitsForTier(Tier.MID));
|
||||
license.setExpiresAt(Instant.now().plus(365, ChronoUnit.DAYS));
|
||||
license.setToken("test-token");
|
||||
licenseRepository.save(license);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEnvironment_shouldReturn201() throws Exception {
|
||||
var request = new CreateEnvironmentRequest("prod", "Production");
|
||||
|
||||
mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
|
||||
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.slug").value("prod"))
|
||||
.andExpect(jsonPath("$.displayName").value("Production"))
|
||||
.andExpect(jsonPath("$.status").value("ACTIVE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEnvironment_duplicateSlug_shouldReturn409() throws Exception {
|
||||
var request = new CreateEnvironmentRequest("staging", "Staging");
|
||||
|
||||
mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
|
||||
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated());
|
||||
|
||||
mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
|
||||
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isConflict());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listEnvironments_shouldReturnAll() throws Exception {
|
||||
var request = new CreateEnvironmentRequest("dev", "Development");
|
||||
|
||||
mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
|
||||
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated());
|
||||
|
||||
mockMvc.perform(get("/api/tenants/" + tenantId + "/environments")
|
||||
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].slug").value("dev"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateEnvironment_shouldReturn200() throws Exception {
|
||||
var createRequest = new CreateEnvironmentRequest("qa", "QA");
|
||||
|
||||
var createResult = mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
|
||||
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(createRequest)))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn();
|
||||
|
||||
String environmentId = objectMapper.readTree(createResult.getResponse().getContentAsString())
|
||||
.get("id").asText();
|
||||
|
||||
var updateRequest = new UpdateEnvironmentRequest("QA Updated");
|
||||
|
||||
mockMvc.perform(patch("/api/tenants/" + tenantId + "/environments/" + environmentId)
|
||||
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(updateRequest)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.displayName").value("QA Updated"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteDefaultEnvironment_shouldReturn403() throws Exception {
|
||||
var request = new CreateEnvironmentRequest("default", "Default");
|
||||
|
||||
var createResult = mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
|
||||
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn();
|
||||
|
||||
String environmentId = objectMapper.readTree(createResult.getResponse().getContentAsString())
|
||||
.get("id").asText();
|
||||
|
||||
mockMvc.perform(delete("/api/tenants/" + tenantId + "/environments/" + environmentId)
|
||||
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEnvironment_noAuth_shouldReturn401() throws Exception {
|
||||
var request = new CreateEnvironmentRequest("no-auth", "No Auth");
|
||||
|
||||
mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package net.siegeln.cameleer.saas.environment;
|
||||
|
||||
import net.siegeln.cameleer.saas.audit.AuditAction;
|
||||
import net.siegeln.cameleer.saas.audit.AuditService;
|
||||
import net.siegeln.cameleer.saas.license.LicenseEntity;
|
||||
import net.siegeln.cameleer.saas.license.LicenseRepository;
|
||||
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class EnvironmentServiceTest {
|
||||
|
||||
@Mock
|
||||
private EnvironmentRepository environmentRepository;
|
||||
|
||||
@Mock
|
||||
private LicenseRepository licenseRepository;
|
||||
|
||||
@Mock
|
||||
private AuditService auditService;
|
||||
|
||||
@Mock
|
||||
private RuntimeConfig runtimeConfig;
|
||||
|
||||
private EnvironmentService environmentService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
environmentService = new EnvironmentService(environmentRepository, licenseRepository, auditService, runtimeConfig);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_shouldCreateEnvironmentAndLogAudit() {
|
||||
var tenantId = UUID.randomUUID();
|
||||
var actorId = UUID.randomUUID();
|
||||
var license = new LicenseEntity();
|
||||
license.setTenantId(tenantId);
|
||||
license.setTier("HIGH");
|
||||
|
||||
when(environmentRepository.existsByTenantIdAndSlug(tenantId, "prod")).thenReturn(false);
|
||||
when(licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId))
|
||||
.thenReturn(Optional.of(license));
|
||||
when(environmentRepository.countByTenantId(tenantId)).thenReturn(0L);
|
||||
when(runtimeConfig.getBootstrapToken()).thenReturn("test-token");
|
||||
when(environmentRepository.save(any(EnvironmentEntity.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
var result = environmentService.create(tenantId, "prod", "Production", actorId);
|
||||
|
||||
assertThat(result.getSlug()).isEqualTo("prod");
|
||||
assertThat(result.getDisplayName()).isEqualTo("Production");
|
||||
assertThat(result.getTenantId()).isEqualTo(tenantId);
|
||||
assertThat(result.getBootstrapToken()).isEqualTo("test-token");
|
||||
|
||||
var actionCaptor = ArgumentCaptor.forClass(AuditAction.class);
|
||||
verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any());
|
||||
assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.ENVIRONMENT_CREATE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_shouldRejectDuplicateSlug() {
|
||||
var tenantId = UUID.randomUUID();
|
||||
var actorId = UUID.randomUUID();
|
||||
|
||||
when(environmentRepository.existsByTenantIdAndSlug(tenantId, "prod")).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> environmentService.create(tenantId, "prod", "Production", actorId))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_shouldEnforceTierLimit() {
|
||||
var tenantId = UUID.randomUUID();
|
||||
var actorId = UUID.randomUUID();
|
||||
var license = new LicenseEntity();
|
||||
license.setTenantId(tenantId);
|
||||
license.setTier("LOW");
|
||||
|
||||
when(environmentRepository.existsByTenantIdAndSlug(tenantId, "staging")).thenReturn(false);
|
||||
when(licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId))
|
||||
.thenReturn(Optional.of(license));
|
||||
when(environmentRepository.countByTenantId(tenantId)).thenReturn(1L);
|
||||
|
||||
assertThatThrownBy(() -> environmentService.create(tenantId, "staging", "Staging", actorId))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listByTenantId_shouldReturnEnvironments() {
|
||||
var tenantId = UUID.randomUUID();
|
||||
var env1 = new EnvironmentEntity();
|
||||
env1.setSlug("default");
|
||||
var env2 = new EnvironmentEntity();
|
||||
env2.setSlug("prod");
|
||||
|
||||
when(environmentRepository.findByTenantId(tenantId)).thenReturn(List.of(env1, env2));
|
||||
|
||||
var result = environmentService.listByTenantId(tenantId);
|
||||
|
||||
assertThat(result).hasSize(2);
|
||||
assertThat(result).extracting(EnvironmentEntity::getSlug).containsExactly("default", "prod");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getById_shouldReturnEnvironment() {
|
||||
var id = UUID.randomUUID();
|
||||
var env = new EnvironmentEntity();
|
||||
env.setSlug("prod");
|
||||
|
||||
when(environmentRepository.findById(id)).thenReturn(Optional.of(env));
|
||||
|
||||
var result = environmentService.getById(id);
|
||||
|
||||
assertThat(result).isPresent();
|
||||
assertThat(result.get().getSlug()).isEqualTo("prod");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDisplayName_shouldUpdateAndLogAudit() {
|
||||
var environmentId = UUID.randomUUID();
|
||||
var actorId = UUID.randomUUID();
|
||||
var env = new EnvironmentEntity();
|
||||
env.setSlug("prod");
|
||||
env.setDisplayName("Old Name");
|
||||
env.setTenantId(UUID.randomUUID());
|
||||
|
||||
when(environmentRepository.findById(environmentId)).thenReturn(Optional.of(env));
|
||||
when(environmentRepository.save(any(EnvironmentEntity.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
var result = environmentService.updateDisplayName(environmentId, "New Name", actorId);
|
||||
|
||||
assertThat(result.getDisplayName()).isEqualTo("New Name");
|
||||
|
||||
var actionCaptor = ArgumentCaptor.forClass(AuditAction.class);
|
||||
verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any());
|
||||
assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.ENVIRONMENT_UPDATE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_shouldRejectDefaultEnvironment() {
|
||||
var environmentId = UUID.randomUUID();
|
||||
var actorId = UUID.randomUUID();
|
||||
var env = new EnvironmentEntity();
|
||||
env.setSlug("default");
|
||||
|
||||
when(environmentRepository.findById(environmentId)).thenReturn(Optional.of(env));
|
||||
|
||||
assertThatThrownBy(() -> environmentService.delete(environmentId, actorId))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createDefaultForTenant_shouldCreateWithDefaultSlug() {
|
||||
var tenantId = UUID.randomUUID();
|
||||
var license = new LicenseEntity();
|
||||
license.setTenantId(tenantId);
|
||||
license.setTier("LOW");
|
||||
|
||||
when(environmentRepository.findByTenantIdAndSlug(tenantId, "default")).thenReturn(Optional.empty());
|
||||
when(environmentRepository.existsByTenantIdAndSlug(tenantId, "default")).thenReturn(false);
|
||||
when(licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId))
|
||||
.thenReturn(Optional.of(license));
|
||||
when(environmentRepository.countByTenantId(tenantId)).thenReturn(0L);
|
||||
when(runtimeConfig.getBootstrapToken()).thenReturn("test-token");
|
||||
when(environmentRepository.save(any(EnvironmentEntity.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
var result = environmentService.createDefaultForTenant(tenantId);
|
||||
|
||||
assertThat(result.getSlug()).isEqualTo("default");
|
||||
assertThat(result.getDisplayName()).isEqualTo("Default");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package net.siegeln.cameleer.saas.log;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class ContainerLogServiceTest {
|
||||
|
||||
@Test
|
||||
void buffer_shouldAccumulateEntries() {
|
||||
var buffer = new ConcurrentLinkedQueue<String>();
|
||||
buffer.add("entry1");
|
||||
buffer.add("entry2");
|
||||
assertEquals(2, buffer.size());
|
||||
assertEquals("entry1", buffer.poll());
|
||||
assertEquals(1, buffer.size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package net.siegeln.cameleer.saas.runtime;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class DockerRuntimeOrchestratorTest {
|
||||
|
||||
@Test
|
||||
void runtimeConfig_parseMemoryLimitBytes_megabytes() {
|
||||
assertEquals(512 * 1024 * 1024L, parseMemoryLimit("512m"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void runtimeConfig_parseMemoryLimitBytes_gigabytes() {
|
||||
assertEquals(1024L * 1024 * 1024, parseMemoryLimit("1g"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void runtimeConfig_parseMemoryLimitBytes_bytes() {
|
||||
assertEquals(536870912L, parseMemoryLimit("536870912"));
|
||||
}
|
||||
|
||||
private long parseMemoryLimit(String limit) {
|
||||
var l = limit.trim().toLowerCase();
|
||||
if (l.endsWith("g")) {
|
||||
return Long.parseLong(l.substring(0, l.length() - 1)) * 1024 * 1024 * 1024;
|
||||
} else if (l.endsWith("m")) {
|
||||
return Long.parseLong(l.substring(0, l.length() - 1)) * 1024 * 1024;
|
||||
}
|
||||
return Long.parseLong(l);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package net.siegeln.cameleer.saas.tenant;
|
||||
|
||||
import net.siegeln.cameleer.saas.audit.AuditAction;
|
||||
import net.siegeln.cameleer.saas.audit.AuditService;
|
||||
import net.siegeln.cameleer.saas.environment.EnvironmentService;
|
||||
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
|
||||
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -32,11 +33,14 @@ class TenantServiceTest {
|
||||
@Mock
|
||||
private LogtoManagementClient logtoClient;
|
||||
|
||||
@Mock
|
||||
private EnvironmentService environmentService;
|
||||
|
||||
private TenantService tenantService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
tenantService = new TenantService(tenantRepository, auditService, logtoClient);
|
||||
tenantService = new TenantService(tenantRepository, auditService, logtoClient, environmentService);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user