40 Commits

Author SHA1 Message Date
hsiegeln
af04f7b4a1 ci: add nightly SonarQube analysis workflow
All checks were successful
CI / build (push) Successful in 45s
CI / build (pull_request) Successful in 46s
CI / docker (pull_request) Has been skipped
CI / docker (push) Successful in 2m29s
Runs at 02:00 UTC daily (same schedule as cameleer3 and cameleer3-server).
Uses cameleer-build:1 image, excludes TestContainers integration tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:08:35 +02:00
hsiegeln
abc06f57da feat: update Docker Compose, CI, and add runtime-base Dockerfile
Some checks failed
CI / build (push) Successful in 57s
CI / build (pull_request) Successful in 54s
CI / docker (pull_request) Has been skipped
CI / docker (push) Has been cancelled
Add jardata volume, CAMELEER_AUTH_TOKEN/CAMELEER3_SERVER_ENDPOINT/CLICKHOUSE_URL env vars to cameleer-saas, CAMELEER_AUTH_TOKEN to cameleer3-server, runtime-base Dockerfile for agent-instrumented customer apps, and expand CI surefire excludes for new integration test classes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:04:42 +02:00
hsiegeln
0bd54f2a95 feat: add container log service with ClickHouse storage and log API
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:02:42 +02:00
hsiegeln
fc34626a88 feat: add deployment controller with deploy/stop/restart endpoints
Add DeploymentResponse DTO, DeploymentController at /api/apps/{appId} with POST /deploy (202), GET /deployments, GET /deployments/{id}, POST /stop, POST /restart (202), and integration tests covering empty list, 404, and 401 cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:00:23 +02:00
hsiegeln
59df59f406 feat: add deployment service with async pipeline
Implements DeploymentService with TDD: builds Docker images, starts containers with Cameleer env vars, polls for health, and handles stop/restart lifecycle. All 3 unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:57:09 +02:00
hsiegeln
23a474fbf3 feat: add deployment entity, repository, and status enums 2026-04-04 17:54:08 +02:00
hsiegeln
d2ea256cd8 feat: add app controller with multipart JAR upload
Adds AppController at /api/environments/{environmentId}/apps with POST (multipart
metadata+JAR), GET list, GET by ID, PUT jar reupload, and DELETE endpoints.
Also adds CreateAppRequest and AppResponse DTOs, integration tests (AppControllerTest),
and fixes ClickHouseConfig to be excluded in test profile via @Profile("!test").

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:53:10 +02:00
hsiegeln
51f5822364 feat: add app service with JAR upload and tier enforcement
Implements AppService with JAR file storage, SHA-256 checksum computation,
tier-based app limit enforcement via LicenseDefaults, and audit logging.
Four TDD tests all pass covering creation, JAR validation, duplicate slug
rejection, and JAR re-upload.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:47:05 +02:00
hsiegeln
2151801d40 feat: add DockerRuntimeOrchestrator with docker-java
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:44:34 +02:00
hsiegeln
90c1e36cb7 feat: add RuntimeOrchestrator interface and request/response types 2026-04-04 17:42:56 +02:00
hsiegeln
731690191b feat: add app entity and repository 2026-04-04 17:42:08 +02:00
hsiegeln
36069bae07 feat: auto-create default environment on tenant provisioning 2026-04-04 17:41:23 +02:00
hsiegeln
785bdab3d1 feat: add environment controller with CRUD endpoints
Implements POST/GET/PATCH/DELETE endpoints at /api/tenants/{tenantId}/environments
with DTOs, mapping helpers, and a Spring Boot integration test (TestContainers).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:40:23 +02:00
hsiegeln
34e98ab176 feat: add environment service with tier enforcement and audit logging
Implements EnvironmentService with full CRUD, duplicate slug rejection,
tier-based environment count limits, and audit logging for create/update/delete.
Adds ENVIRONMENT_CREATE, ENVIRONMENT_UPDATE, ENVIRONMENT_DELETE to AuditAction.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:36:09 +02:00
hsiegeln
8511d10343 feat: add environment entity, repository, and status enum 2026-04-04 17:33:43 +02:00
hsiegeln
4cb15c9bea feat: add database migrations for environments, apps, deployments 2026-04-04 17:32:51 +02:00
hsiegeln
bd8dfcf147 fix: use concrete ClickHouseDataSource return type to avoid bean ambiguity 2026-04-04 17:32:09 +02:00
hsiegeln
803b8c9876 feat: add Phase 3 dependencies and configuration
Add docker-java and ClickHouse JDBC dependencies, RuntimeConfig and
ClickHouseConfig Spring components, AsyncConfig with deployment thread
pool, and runtime/clickhouse config sections in application.yml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:29:06 +02:00
hsiegeln
c0fce36d4a chore: add .worktrees to .gitignore 2026-04-04 17:26:22 +02:00
hsiegeln
fa7853b02d docs: add Phase 3 Runtime Orchestration implementation plan
16-task plan covering environments, apps, deployments, Docker
runtime orchestrator, ClickHouse log ingestion, and CI updates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:24:20 +02:00
hsiegeln
0326dc6cce docs: add Phase 3 Runtime Orchestration spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:13:08 +02:00
5d14f78b9d Merge pull request 'Phase 2: Tenants + Identity + Licensing' (#32) from feature/phase-2-tenants-identity-licensing into main
All checks were successful
CI / build (push) Successful in 25s
CI / docker (push) Successful in 31s
Reviewed-on: #32
2026-04-04 15:58:07 +02:00
hsiegeln
eb4e0b2b07 fix: exclude TestContainers integration tests from CI
All checks were successful
CI / build (push) Successful in 59s
CI / build (pull_request) Successful in 59s
CI / docker (pull_request) Has been skipped
CI / docker (push) Successful in 2m39s
Build container has no Docker-in-Docker, so TestContainers can't
create PostgreSQL containers. Exclude integration tests in CI;
they run locally with Docker Desktop. Matches cameleer3-server
pattern of separating unit and integration tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 15:54:53 +02:00
hsiegeln
cd866ec7fe ci: retrigger pipeline with updated Java 21 build image
Some checks failed
CI / build (push) Failing after 1m19s
CI / docker (push) Has been skipped
CI / build (pull_request) Failing after 1m18s
CI / docker (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 15:50:37 +02:00
hsiegeln
b0eba3c709 feat: adopt cameleer build images for CI pipeline
Some checks failed
CI / build (push) Failing after 36s
CI / docker (push) Has been skipped
CI / build (pull_request) Failing after 1m28s
CI / docker (pull_request) Has been skipped
Use cameleer-build:1 (Maven 3.9 + Temurin 21) container instead of
setup-java. Use cameleer-docker-builder:1 for Docker image builds
with registry push. Aligns with cameleer3-server CI pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 15:40:49 +02:00
hsiegeln
d9f0da6e91 fix: set execute permission on Maven wrapper
Some checks failed
CI / build (pull_request) Failing after 1m43s
CI runner (Linux) requires mvnw to be executable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 15:34:52 +02:00
hsiegeln
0e3d314dd1 fix: upgrade TestContainers to 1.21.4 for Docker 29 compatibility
Some checks failed
CI / build (pull_request) Failing after 57s
Docker Desktop 4.54 (Engine 29.1.2) raised minimum API from 1.24 to
1.44. TestContainers 1.20.5 defaults to 1.32 which gets rejected.
TC 1.21.4 handles API version negotiation natively.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 15:28:12 +02:00
hsiegeln
db7647f7f4 refactor: remove Phase 1 auth endpoints, switch to Logto OIDC
Auth is now handled by Logto. Removed AuthController, AuthService,
and related DTOs. Integration tests use Spring Security JWT mocks.
Ed25519 JwtService retained for machine token signing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 15:12:50 +02:00
hsiegeln
ab9ad1ab7f feat: add Docker Compose production stack with Traefik + Logto
7-service stack: Traefik (reverse proxy), PostgreSQL (shared),
Logto (identity), cameleer-saas (control plane), cameleer3-server
(observability), ClickHouse (traces). ForwardAuth middleware for
tenant-aware routing to cameleer3-server.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 15:09:49 +02:00
hsiegeln
42bd116af1 feat: add Logto Management API client for org provisioning
Creates Logto organizations when tenants are created. Authenticates
via M2M client credentials. Gracefully skips when Logto is not
configured (dev/test mode).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 15:07:43 +02:00
hsiegeln
0f3bd209a1 feat: add ForwardAuth endpoint for Traefik integration
GET /auth/verify validates JWT and returns X-User-Id, X-User-Email
headers for downstream service routing via Traefik middleware.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 15:05:13 +02:00
hsiegeln
e58e2caf8e feat: add tenant context resolution from Logto organization_id claim
TenantResolutionFilter extracts organization_id from Logto JWT and
resolves to local tenant via TenantService. ThreadLocal TenantContext
available throughout request lifecycle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 15:05:05 +02:00
hsiegeln
0d9c51843d feat: add OAuth2 Resource Server for Logto OIDC authentication
Dual auth: machine endpoints use Ed25519 JWT filter, all other API
endpoints use Spring Security OAuth2 Resource Server with Logto OIDC.
Mock JwtDecoder provided for test isolation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 15:03:06 +02:00
hsiegeln
9a575eaa94 feat: add license controller with generate and fetch endpoints
POST /api/tenants/{id}/license generates Ed25519-signed license JWT.
GET /api/tenants/{id}/license returns active license.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 15:00:31 +02:00
hsiegeln
d987969e05 feat: add license service with Ed25519 JWT signing and verification
Generates tier-aware license tokens with features/limits per tier.
Verifies signature and expiry. Audit logged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 14:58:56 +02:00
hsiegeln
a74894e0f1 feat: add license entity, repository, and database migration
Licenses table linked to tenants with JSONB features/limits, Ed25519
signed token storage, and revocation support.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 14:56:52 +02:00
hsiegeln
c1cae25db7 feat: add tenant service, controller, and DTOs with TDD
CRUD operations for tenants with slug-based lookup, tier management,
and audit logging. Integration tests verify 201/409/401 responses.
2026-04-04 14:53:58 +02:00
hsiegeln
119034307c feat: add tenant entity, repository, and database migration
Tenants table with slug, tier (LOW/MID/HIGH/BUSINESS), status
(PROVISIONING/ACTIVE/SUSPENDED/DELETED), Logto org reference, and
Stripe IDs.
2026-04-04 14:53:51 +02:00
hsiegeln
0a2d5970e4 feat: externalize Ed25519 keys with file-based loading
Keys are loaded from PEM files when CAMELEER_JWT_PRIVATE_KEY_PATH and
CAMELEER_JWT_PUBLIC_KEY_PATH are set. Falls back to ephemeral key
generation for development.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 14:48:20 +02:00
hsiegeln
24309eab94 docs: add dual deployment architecture spec and Phase 2 plan
Architecture spec covers Docker+K8s dual deployment with build-vs-buy
decisions (Logto, Traefik, Stripe, deferred Lago/Vault). Phase 2 plan
has 12 implementation tasks for tenants, identity, and licensing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 14:45:33 +02:00
98 changed files with 11747 additions and 506 deletions

29
.env.example Normal file
View File

@@ -0,0 +1,29 @@
# Cameleer SaaS Environment Variables
# Copy to .env and fill in values
# Application version
VERSION=latest
# PostgreSQL
POSTGRES_USER=cameleer
POSTGRES_PASSWORD=change_me_in_production
POSTGRES_DB=cameleer_saas
# Logto Identity Provider
LOGTO_ENDPOINT=http://logto:3001
LOGTO_ISSUER_URI=http://logto:3001/oidc
LOGTO_JWK_SET_URI=http://logto:3001/oidc/jwks
LOGTO_DB_PASSWORD=change_me_in_production
LOGTO_M2M_CLIENT_ID=
LOGTO_M2M_CLIENT_SECRET=
# Ed25519 Keys (mount PEM files)
CAMELEER_JWT_PRIVATE_KEY_PATH=/etc/cameleer/keys/ed25519.key
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

View File

@@ -1,45 +1,84 @@
# .gitea/workflows/ci.yml
name: CI
on:
push:
branches: [main]
branches: [main, 'feature/**', 'fix/**', 'feat/**']
tags-ignore:
- 'v*'
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: cameleer_saas_test
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
if: github.event_name != 'delete'
container:
image: gitea.siegeln.net/cameleer/cameleer-build:1
credentials:
username: cameleer
password: ${{ secrets.REGISTRY_TOKEN }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
- name: Cache Maven dependencies
uses: actions/cache@v4
with:
distribution: temurin
java-version: 21
cache: maven
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: ${{ runner.os }}-maven-
- name: Run tests
run: ./mvnw verify -B
- name: Build and Test (unit tests only)
run: >-
mvn clean verify -B
-Dsurefire.excludes="**/AuthControllerTest.java,**/TenantControllerTest.java,**/LicenseControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java,**/EnvironmentControllerTest.java,**/AppControllerTest.java,**/DeploymentControllerTest.java"
docker:
needs: build
runs-on: ubuntu-latest
if: github.event_name == 'push'
container:
image: gitea.siegeln.net/cameleer/cameleer-docker-builder:1
credentials:
username: cameleer
password: ${{ secrets.REGISTRY_TOKEN }}
steps:
- name: Checkout
run: |
git clone --depth=1 --branch=${GITHUB_REF_NAME} https://cameleer:${REGISTRY_TOKEN}@gitea.siegeln.net/${GITHUB_REPOSITORY}.git .
env:
SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/cameleer_saas_test
SPRING_DATASOURCE_USERNAME: test
SPRING_DATASOURCE_PASSWORD: test
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
- name: Build Docker image
run: docker build -t cameleer-saas:${{ github.sha }} .
- name: Login to registry
run: echo "$REGISTRY_TOKEN" | docker login gitea.siegeln.net -u cameleer --password-stdin
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
- name: Compute image tags
run: |
sanitize_branch() {
echo "$1" | sed -E 's#^(feature|fix|feat|hotfix)/##' \
| tr '[:upper:]' '[:lower:]' \
| sed 's/[^a-z0-9-]/-/g' \
| sed 's/--*/-/g; s/^-//; s/-$//' \
| cut -c1-20 \
| sed 's/-$//'
}
if [ "$GITHUB_REF_NAME" = "main" ]; then
echo "IMAGE_TAGS=latest" >> "$GITHUB_ENV"
else
SLUG=$(sanitize_branch "$GITHUB_REF_NAME")
echo "IMAGE_TAGS=branch-$SLUG" >> "$GITHUB_ENV"
fi
- name: Build and push
run: |
TAGS="-t gitea.siegeln.net/cameleer/cameleer-saas:${{ github.sha }}"
for TAG in $IMAGE_TAGS; do
TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer-saas:$TAG"
done
docker build $TAGS --provenance=false .
for TAG in $IMAGE_TAGS ${{ github.sha }}; do
docker push gitea.siegeln.net/cameleer/cameleer-saas:$TAG
done
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}

View 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
View File

@@ -18,3 +18,6 @@ Thumbs.db
# Environment
.env
*.env.local
# Worktrees
.worktrees/

21
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,21 @@
# Development overrides: exposes ports for direct access
# Usage: docker compose -f docker-compose.yml -f docker-compose.dev.yml up
services:
postgres:
ports:
- "5432:5432"
logto:
ports:
- "3001:3001"
- "3002:3002"
cameleer-saas:
ports:
- "8080:8080"
environment:
SPRING_PROFILES_ACTIVE: dev
clickhouse:
ports:
- "8123:8123"

View File

@@ -1,14 +1,128 @@
services:
traefik:
image: traefik:v3
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik.yml:/etc/traefik/traefik.yml:ro
- acme:/etc/traefik/acme
networks:
- cameleer
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: cameleer_saas
POSTGRES_USER: cameleer
POSTGRES_PASSWORD: cameleer_dev
ports:
- "5432:5432"
POSTGRES_DB: ${POSTGRES_DB:-cameleer_saas}
POSTGRES_USER: ${POSTGRES_USER:-cameleer}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
volumes:
- pgdata:/var/lib/postgresql/data
- ./docker/init-databases.sh:/docker-entrypoint-initdb.d/init-databases.sh:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-cameleer}"]
interval: 5s
timeout: 5s
retries: 5
networks:
- cameleer
logto:
image: ghcr.io/logto-io/logto:latest
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"]
environment:
DB_URL: postgres://${POSTGRES_USER:-cameleer}:${POSTGRES_PASSWORD:-cameleer_dev}@postgres:5432/logto
ENDPOINT: ${LOGTO_ENDPOINT:-http://localhost:3001}
ADMIN_ENDPOINT: ${LOGTO_ADMIN_ENDPOINT:-http://localhost:3002}
TRUST_PROXY_HEADER: 1
labels:
- traefik.enable=true
- traefik.http.routers.logto.rule=PathPrefix(`/oidc`) || PathPrefix(`/interaction`)
- traefik.http.services.logto.loadbalancer.server.port=3001
networks:
- cameleer
cameleer-saas:
image: ${CAMELEER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-saas}:${VERSION:-latest}
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
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}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
LOGTO_ENDPOINT: ${LOGTO_ENDPOINT:-http://logto:3001}
LOGTO_ISSUER_URI: ${LOGTO_ISSUER_URI:-http://logto:3001/oidc}
LOGTO_JWK_SET_URI: ${LOGTO_JWK_SET_URI:-http://logto:3001/oidc/jwks}
LOGTO_M2M_CLIENT_ID: ${LOGTO_M2M_CLIENT_ID:-}
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`)
- traefik.http.services.api.loadbalancer.server.port=8080
- traefik.http.routers.forwardauth.rule=Path(`/auth/verify`)
- traefik.http.services.forwardauth.loadbalancer.server.port=8080
networks:
- cameleer
cameleer3-server:
image: ${CAMELEER3_SERVER_IMAGE:-gitea.siegeln.net/cameleer/cameleer3-server}:${VERSION:-latest}
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
clickhouse:
condition: service_started
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`)
- traefik.http.routers.observe.middlewares=forward-auth
- traefik.http.middlewares.forward-auth.forwardauth.address=http://cameleer-saas:8080/auth/verify
- traefik.http.middlewares.forward-auth.forwardauth.authResponseHeaders=X-Tenant-Id,X-User-Id,X-User-Email
- traefik.http.services.observe.loadbalancer.server.port=8080
networks:
- cameleer
clickhouse:
image: clickhouse/clickhouse-server:latest
restart: unless-stopped
volumes:
- chdata:/var/lib/clickhouse
healthcheck:
test: ["CMD-SHELL", "clickhouse-client --query 'SELECT 1'"]
interval: 10s
timeout: 5s
retries: 3
networks:
- cameleer
networks:
cameleer:
driver: bridge
volumes:
pgdata:
chdata:
acme:
jardata:

7
docker/init-databases.sh Normal file
View File

@@ -0,0 +1,7 @@
#!/bin/bash
set -e
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL
CREATE DATABASE logto;
GRANT ALL PRIVILEGES ON DATABASE logto TO $POSTGRES_USER;
EOSQL

View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,399 @@
# Dual Deployment Architecture: Docker + Kubernetes
**Date:** 2026-04-04
**Status:** Approved
**Supersedes:** Portions of `2026-03-29-saas-platform-prd.md` (deployment model, phase ordering, auth strategy)
## Context
Cameleer SaaS must serve two deployment targets:
- **Docker Compose** — production-viable for small customers and air-gapped installs (single-tenant per stack)
- **Kubernetes** — managed SaaS and enterprise self-hosted (multi-tenant)
The original PRD assumed K8s-only production. This design restructures the architecture and roadmap to treat Docker Compose as a first-class production target, uses the Docker+K8s dual requirement as a filter for build-vs-buy decisions, and reorders the phase roadmap to ship a deployable product faster.
Key constraints:
- The application is **always multi-tenant** — Docker deployments have exactly 1 tenant
- Don't build custom abstractions over K8s-only primitives when no Docker equivalent exists
- Prefer right-sized OSS tools over Swiss Army knives or custom builds
- K8s-only features (NetworkPolicies, HPA, Flux CD) are operational enhancements, never functional requirements
## Build-vs-Buy Decisions
### BUY (Use 3rd Party OSS)
| Subsystem | Tool | License | Why This Tool |
|---|---|---|---|
| **Identity & Auth** | **Logto** | MPL-2.0 | Lightest IdP (2 containers, ~0.5-1 GB). Orgs, RBAC, M2M tokens, OIDC/SSO federation all in OSS. Replaces ~3-4 months of custom auth build (OIDC, SSO, teams, invites, MFA, password reset, custom roles). |
| **Reverse Proxy** | **Traefik** | MIT | Native Docker provider (labels) and K8s provider (IngressRoute CRDs). Same mental model in both environments. Already on the k3s cluster. ForwardAuth middleware for tenant-aware routing. Auto-HTTPS via Let's Encrypt. ~256 MB RAM. |
| **Database** | **PostgreSQL** | PostgreSQL License | Already chosen. Platform data + Logto data (separate schemas). |
| **Trace/Metrics Storage** | **ClickHouse** | Apache-2.0 | Replaced OpenSearch in the cameleer3-server stack. Columnar OLAP, excellent for time-series observability data. |
| **Schema Migrations** | **Flyway** | Apache-2.0 | Already in place. |
| **Billing (subscriptions)** | **Stripe** | N/A (API) | Start with Stripe Checkout for fixed-tier subscriptions. No custom billing infrastructure day 1. |
| **Billing (usage metering)** | **Lago** (deferred) | AGPL-3.0 | Purpose-built for event-based metering. 8 containers — deploy only when usage-based pricing launches. Design event model with Lago's API shape in mind from day 1. Integrate via API only (keeps AGPL safe). |
| **GitOps (K8s only)** | **Flux CD** | Apache-2.0 | K8s-only, and that's acceptable. Docker deployments get release tarballs + upgrade scripts. |
| **Image Builds (K8s)** | **Kaniko** | Apache-2.0 | Daemonless container image builds inside K8s. For Docker mode, `docker build` via docker-java is simpler. |
| **Monitoring** | **Prometheus + Grafana + Loki** | Apache-2.0 | Works in both Docker and K8s. Optional for Docker (customer's choice), standard for K8s SaaS. |
| **TLS Certificates** | **Traefik ACME** (Docker) / **cert-manager** (K8s) | MIT / Apache-2.0 | Standard tools, no custom code. |
| **Container Registry (K8s)** | **Gitea Registry** (SaaS) / **registry:2** (self-hosted) | — | Docker mode doesn't need a registry (local image cache). |
### BUILD (Custom / Core IP)
| Subsystem | Why Build |
|---|---|
| **License signing & validation** | Ed25519 signed JWT with tier, features, limits, expiry. Dual mode: online API check + offline signed file. No off-the-shelf tool does this. Core IP. |
| **Agent bootstrap tokens** | Tightly coupled to the cameleer3 agent protocol (PROTOCOL.md). Custom Ed25519 tokens for agent registration. |
| **Tenant lifecycle** | CRUD, configuration, status management. Core business logic. User management (invites, teams, roles) is delegated to Logto's organization model. |
| **Runtime orchestration** | The core of the "managed Camel runtime" product. `RuntimeOrchestrator` interface with Docker and K8s implementations. No off-the-shelf tool does "managed Camel runtime with agent injection." |
| **Image build pipeline** | Templated Dockerfile: JRE + cameleer3-agent.jar + customer JAR + `-javaagent` flag. Simple but custom. |
| **Feature gating** | Tier-based feature gating logic. Which features are available at which tier. Business logic. |
| **Billing integration** | Stripe API calls, subscription lifecycle, webhook handling. Thin integration layer. |
| **Observability proxy** | Routing authenticated requests to tenant-specific cameleer3-server instances. |
| **MOAT features** | Debugger, Lineage, Correlation — the defensible product. Built in cameleer3 agent + server. |
### SKIP / DEFER
| Subsystem | Why Skip |
|---|---|
| **Secrets management (Vault)** | Docker: env vars + mounted files. K8s: K8s Secrets. Vault is enterprise-tier complexity. Defer until demanded. |
| **Custom role management UI** | Logto provides this. |
| **OIDC provider implementation** | Logto provides this. |
| **WireGuard VPN / VPC peering** | Far future, dedicated-tier only. |
| **Cluster API for dedicated tiers** | Don't design for this until enterprise customers exist. |
| **Management agent for updates** | Watchtower is optional for connected customers. Air-gapped gets release tarballs. Don't build custom. |
## Architecture
### Platform Stack (Docker Compose — 6 base containers)
```
+-------------------------------------------------------+
| Traefik (reverse proxy, TLS, ForwardAuth) |
| - Docker: labels-based routing |
| - K8s: IngressRoute CRDs |
+--------+---------------------+------------------------+
| |
+--------v--------+ +---------v-----------+
| cameleer-saas | | cameleer3-server |
| (Spring Boot) | | (observability) |
| Control plane | | Per-tenant instance |
+---+-------+-----+ +----------+----------+
| | |
+---v--+ +--v----+ +---------v---------+
| PG | | Logto | | ClickHouse |
| | | (IdP) | | (traces/metrics) |
+------+ +-------+ +-------------------+
```
Customer Camel apps are **additional containers** dynamically managed by the control plane via Docker API (Docker mode) or K8s API (K8s mode).
### Auth Flow
```
User login:
Browser -> Traefik -> Logto (OIDC flow) -> JWT issued by Logto
API request:
Browser -> Traefik -> ForwardAuth (cameleer-saas /auth/verify)
-> Validates Logto JWT, injects X-Tenant-Id header
-> Traefik forwards to upstream service
Machine auth (agent bootstrap):
cameleer3-agent -> cameleer-saas /api/agent/register
-> Validates bootstrap token (Ed25519)
-> Issues agent session token
-> Agent connects to cameleer3-server
```
Logto handles all user-facing identity. The cameleer-saas app handles machine-to-machine auth (agent tokens, license tokens) using Ed25519.
### Runtime Orchestration
```java
RuntimeOrchestrator (interface)
+ deployApp(tenantId, appId, envId, imageRef, config) -> Deployment
+ stopApp(tenantId, appId, envId) -> void
+ restartApp(tenantId, appId, envId) -> void
+ getAppLogs(tenantId, appId, envId, since) -> Stream<LogLine>
+ getAppStatus(tenantId, appId, envId) -> AppStatus
+ listApps(tenantId) -> List<AppSummary>
DockerRuntimeOrchestrator (docker-java library)
- Talks to Docker daemon via /var/run/docker.sock
- Creates containers with labels for Traefik routing
- Manages container lifecycle
- Builds images locally via docker build
KubernetesRuntimeOrchestrator (fabric8 kubernetes-client)
- Creates Deployments, Services, ConfigMaps in tenant namespace
- Builds images via Kaniko Jobs, pushes to registry
- Manages rollout lifecycle
```
### Image Build Pipeline
```
Customer uploads JAR
-> Validation (file type, size, SHA-256, security scan)
-> Templated Dockerfile generation:
FROM eclipse-temurin:21-jre-alpine
COPY cameleer3-agent.jar /opt/agent/
COPY customer-app.jar /opt/app/
ENTRYPOINT ["java", "-javaagent:/opt/agent/cameleer3-agent.jar", "-jar", "/opt/app/customer-app.jar"]
-> Build:
Docker mode: docker build via docker-java (local image cache)
K8s mode: Kaniko Job -> push to registry
-> Deploy to requested environment
```
### Multi-Tenancy Model
- **Always multi-tenant.** Docker Compose has 1 pre-configured tenant.
- **Schema-per-tenant** in PostgreSQL for platform data isolation.
- **Logto organizations** map 1:1 to tenants. Logto handles user-tenant membership.
- **ClickHouse** data partitioned by tenant_id.
- **cameleer3-server** instances are per-tenant (separate containers/pods).
- **K8s bonus:** Namespace-per-tenant for network isolation, resource quotas.
### Environment Model
Each tenant can have multiple logical environments (tier-dependent):
| Tier | Environments |
|---|---|
| Low | prod only |
| Mid | dev, prod |
| High+ | dev, staging, prod + custom |
Each environment is a separate deployment of the same app image with different configuration:
- Docker: separate container, different env vars
- K8s: separate Deployment, different ConfigMap
Promotion = deploy same image tag to a different environment with that environment's config.
### Configuration Strategy
The application is configured entirely via environment variables and Spring Boot profiles:
```yaml
# Detected at startup
cameleer.deployment.mode: docker | kubernetes # auto-detected
cameleer.deployment.docker.socket: /var/run/docker.sock
cameleer.deployment.k8s.namespace-template: tenant-{tenantId}
# Identity provider
cameleer.identity.issuer-uri: http://logto:3001/oidc
cameleer.identity.client-id: ${LOGTO_CLIENT_ID}
cameleer.identity.client-secret: ${LOGTO_CLIENT_SECRET}
# Ed25519 keys (externalized, not per-boot)
cameleer.jwt.private-key-path: /etc/cameleer/keys/ed25519.key
cameleer.jwt.public-key-path: /etc/cameleer/keys/ed25519.pub
# Database
spring.datasource.url: ${DATABASE_URL}
# ClickHouse
cameleer.clickhouse.url: ${CLICKHOUSE_URL}
```
### Docker Compose Production Template
```yaml
services:
traefik:
image: traefik:v3
ports: ["80:80", "443:443"]
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik.yml:/etc/traefik/traefik.yml
- acme:/etc/traefik/acme
labels:
# Dashboard (optional, secured)
cameleer-saas:
image: gitea.siegeln.net/cameleer/cameleer-saas:${VERSION}
volumes:
- /var/run/docker.sock:/var/run/docker.sock # For runtime orchestration
- ./keys:/etc/cameleer/keys:ro
environment:
- DATABASE_URL=jdbc:postgresql://postgres:5432/cameleer_saas
- LOGTO_CLIENT_ID=${LOGTO_CLIENT_ID}
- LOGTO_CLIENT_SECRET=${LOGTO_CLIENT_SECRET}
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=PathPrefix(`/api`)
logto:
image: svhd/logto:latest
environment:
- DB_URL=postgresql://postgres:5432/logto
labels:
- traefik.enable=true
- traefik.http.routers.auth.rule=PathPrefix(`/auth`)
cameleer3-server:
image: gitea.siegeln.net/cameleer/cameleer3-server:${VERSION}
environment:
- CLICKHOUSE_URL=jdbc:clickhouse://clickhouse:8123/cameleer
labels:
- traefik.enable=true
- traefik.http.routers.observe.rule=PathPrefix(`/observe`)
postgres:
image: postgres:16-alpine
volumes: [pgdata:/var/lib/postgresql/data]
clickhouse:
image: clickhouse/clickhouse-server:latest
volumes: [chdata:/var/lib/clickhouse]
volumes:
pgdata:
chdata:
acme:
```
### Docker vs K8s Feature Matrix
| Feature | Docker Compose | Kubernetes |
|---|---|---|
| Deploy Camel apps | Yes (Docker API) | Yes (K8s API) |
| Multiple environments | Yes (separate containers) | Yes (separate Deployments) |
| Agent injection | Yes | Yes |
| Observability (traces, topology) | Yes | Yes |
| Identity / SSO / Teams | Yes (Logto) | Yes (Logto) |
| Licensing | Yes | Yes |
| Auto-scaling | No | Yes (HPA) |
| Network isolation (multi-tenant) | Docker networks | NetworkPolicies |
| GitOps deployment | No (manual updates) | Yes (Flux CD) |
| Rolling updates | Manual restart | Native |
| Platform monitoring | Optional (customer adds Grafana) | Standard (Prometheus/Grafana/Loki) |
| Certificate management | Traefik ACME | cert-manager |
## Revised Phase Roadmap
### Phase 2: Tenants + Identity + Licensing
**Goal:** A customer can sign up, get a tenant, and access the platform via Traefik.
- Integrate Logto as identity provider
- Replace custom user-facing auth (login, registration, password management)
- Keep Ed25519 JWT for machine tokens (agent bootstrap, license signing)
- Configure Logto organizations to map to tenants
- Tenant entity + CRUD API
- License token generation (Ed25519 signed JWT: tier, features, limits, expiry)
- Traefik integration with ForwardAuth middleware
- Docker Compose production stack (6 containers)
- Externalize Ed25519 keys (mounted files, not per-boot)
**Files to modify/create:**
- `src/main/java/net/siegeln/cameleer/saas/tenant/` — new package
- `src/main/java/net/siegeln/cameleer/saas/license/` — new package
- `src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java` — Logto OIDC integration
- `src/main/resources/db/migration/V005__create_tenants.sql`
- `src/main/resources/db/migration/V006__create_licenses.sql`
- `docker-compose.yml` — expand to full production stack
- `traefik.yml` — static config
- `src/main/resources/application.yml` — Logto + Traefik config
### Phase 3: Runtime Orchestration + Environments
**Goal:** Customer can upload a Camel JAR, deploy it to dev/prod, see it running with agent attached.
- `RuntimeOrchestrator` interface
- `DockerRuntimeOrchestrator` implementation (docker-java)
- Customer JAR upload endpoint
- Image build pipeline (Dockerfile template + docker build)
- Logical environment model (dev/test/prod per tenant)
- Environment-specific config overlays
- App lifecycle API (deploy, start, stop, restart, logs, health)
**Key dependencies:** docker-java, Kaniko (for future K8s)
### Phase 4: Observability Pipeline
**Goal:** Customer can see traces, metrics, and route topology for deployed apps.
- Connect cameleer3-server to customer app containers
- ClickHouse tenant-scoped data partitioning
- Observability API proxy (tenant-aware routing to cameleer3-server)
- Basic topology graph endpoint
- Agent ↔ server connectivity verification
### Phase 5: K8s Operational Layer
**Goal:** Same product works on K8s with operational enhancements.
- `KubernetesRuntimeOrchestrator` implementation (fabric8)
- Kaniko-based image builds
- Flux CD integration for platform GitOps
- Namespace-per-tenant provisioning
- NetworkPolicies, ResourceQuotas
- Helm chart for K8s deployment
- Registry integration (Gitea registry / registry:2)
### Phase 6: Billing
**Goal:** Customers can subscribe and pay.
- Stripe Checkout integration
- Subscription lifecycle (create, upgrade, downgrade, cancel)
- Tier enforcement (feature gating based on active subscription)
- Usage tracking in platform DB (prep for Lago integration later)
- Webhook handling for payment events
### Phase 7: Security Hardening + Monitoring
**Goal:** Production-hardened platform.
- Prometheus/Grafana/Loki stack (optional Docker compose overlay, standard K8s)
- SOC 2 compliance review
- Rate limiting
- Container image signing (cosign)
- Supply chain security (SBOM, Trivy scanning)
- Audit log shipping to separate sink
### Frontend (React Shell) — Parallel Track (Phase 2+)
- Can start as soon as Phase 2 API contracts are defined
- Uses `@cameleer/design-system`
- Screens: login, dashboard, app deployment, environment management, observability views, team management, billing
## Verification Plan
### Phase 2 Verification
1. `docker compose up` starts all 6 containers
2. Navigate to Logto admin, create a user
3. User logs in via OIDC flow through Traefik
4. API calls with JWT include `X-Tenant-Id` header
5. License token can be generated and verified
6. All existing tests still pass
### Phase 3 Verification
1. Upload a sample Camel JAR via API
2. Platform builds container image
3. Deploy to "dev" environment
4. Container starts with cameleer3 agent attached
5. App is reachable via Traefik routing
6. Logs are accessible via API
7. Deploy same image to "prod" with different config
### Phase 4 Verification
1. Running Camel app sends traces to cameleer3-server
2. Traces visible in ClickHouse with correct tenant_id
3. Topology graph shows route structure
4. Different tenant cannot see another tenant's data
### Phase 5 Verification
1. Helm install deploys full platform to k3s
2. Tenant provisioning creates namespace + resources
3. App deployment creates K8s Deployment + Service
4. Kaniko builds image and pushes to registry
5. NetworkPolicy blocks cross-tenant traffic
6. Same API contracts work as Docker mode
### End-to-End Smoke Test (Any Phase)
```bash
# Docker Compose
docker compose up -d
# Create tenant + user via API/Logto
# Upload sample Camel JAR
# Deploy to environment
# Verify agent connects to cameleer3-server
# Verify traces in ClickHouse
# Verify observability API returns data
```

View File

@@ -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

0
mvnw vendored Normal file → Executable file
View File

27
pom.xml
View File

@@ -19,6 +19,7 @@
<properties>
<java.version>21</java.version>
<testcontainers.version>1.21.4</testcontainers.version>
</properties>
<dependencies>
@@ -34,6 +35,12 @@
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- OAuth2 Resource Server (Logto OIDC) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<!-- JPA + PostgreSQL -->
<dependency>
<groupId>org.springframework.boot</groupId>
@@ -73,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>

View 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()
);
}
}

View 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; }
}

View File

@@ -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);
}

View 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");
}
}
}

View File

@@ -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
) {}

View File

@@ -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
) {}

View File

@@ -3,8 +3,10 @@ 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,
TEAM_INVITE, TEAM_REMOVE, TEAM_ROLE_CHANGE
TEAM_INVITE, TEAM_REMOVE, TEAM_ROLE_CHANGE,
LICENSE_GENERATE, LICENSE_REVOKE
}

View File

@@ -1,54 +0,0 @@
package net.siegeln.cameleer.saas.auth;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import net.siegeln.cameleer.saas.auth.dto.AuthResponse;
import net.siegeln.cameleer.saas.auth.dto.LoginRequest;
import net.siegeln.cameleer.saas.auth.dto.RegisterRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
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;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/register")
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request,
HttpServletRequest httpRequest) {
try {
var response = authService.register(request, extractClientIp(httpRequest));
return ResponseEntity.status(HttpStatus.CREATED).body(response);
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request,
HttpServletRequest httpRequest) {
try {
var response = authService.login(request, extractClientIp(httpRequest));
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}
private String extractClientIp(HttpServletRequest request) {
String xForwardedFor = request.getHeader("X-Forwarded-For");
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
return xForwardedFor.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}

View File

@@ -1,82 +0,0 @@
package net.siegeln.cameleer.saas.auth;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.auth.dto.AuthResponse;
import net.siegeln.cameleer.saas.auth.dto.LoginRequest;
import net.siegeln.cameleer.saas.auth.dto.RegisterRequest;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class AuthService {
private final UserRepository userRepository;
private final RoleRepository roleRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final AuditService auditService;
public AuthService(UserRepository userRepository,
RoleRepository roleRepository,
PasswordEncoder passwordEncoder,
JwtService jwtService,
AuditService auditService) {
this.userRepository = userRepository;
this.roleRepository = roleRepository;
this.passwordEncoder = passwordEncoder;
this.jwtService = jwtService;
this.auditService = auditService;
}
public AuthResponse register(RegisterRequest request, String sourceIp) {
if (userRepository.existsByEmail(request.email())) {
throw new IllegalArgumentException("Email already registered");
}
var user = new UserEntity();
user.setEmail(request.email());
user.setName(request.name());
user.setPassword(passwordEncoder.encode(request.password()));
roleRepository.findByName("OWNER").ifPresent(role -> user.getRoles().add(role));
var saved = userRepository.save(user);
var token = jwtService.generateToken(saved);
auditService.log(
saved.getId(), saved.getEmail(), null,
AuditAction.AUTH_REGISTER, null,
null, sourceIp,
"SUCCESS", null
);
return new AuthResponse(token, saved.getEmail(), saved.getName());
}
public AuthResponse login(LoginRequest request, String sourceIp) {
var user = userRepository.findByEmail(request.email())
.orElseThrow(() -> new IllegalArgumentException("Invalid credentials"));
if (!passwordEncoder.matches(request.password(), user.getPassword())) {
auditService.log(
user.getId(), user.getEmail(), null,
AuditAction.AUTH_LOGIN_FAILED, null,
null, sourceIp,
"FAILURE", null
);
throw new IllegalArgumentException("Invalid credentials");
}
var token = jwtService.generateToken(user);
auditService.log(
user.getId(), user.getEmail(), null,
AuditAction.AUTH_LOGIN, null,
null, sourceIp,
"SUCCESS", null
);
return new AuthResponse(token, user.getEmail(), user.getName());
}
}

View File

@@ -1,8 +0,0 @@
package net.siegeln.cameleer.saas.auth.dto;
public record AuthResponse(
String token,
String email,
String name
) {
}

View File

@@ -1,10 +0,0 @@
package net.siegeln.cameleer.saas.auth.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public record LoginRequest(
@NotBlank @Email String email,
@NotBlank String password
) {
}

View File

@@ -1,12 +0,0 @@
package net.siegeln.cameleer.saas.auth.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record RegisterRequest(
@NotBlank @Email String email,
@NotBlank String name,
@NotBlank @Size(min = 8, max = 128) String password
) {
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,43 @@
package net.siegeln.cameleer.saas.config;
import net.siegeln.cameleer.saas.auth.JwtService;
import net.siegeln.cameleer.saas.tenant.TenantService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import jakarta.servlet.http.HttpServletRequest;
@RestController
public class ForwardAuthController {
private final JwtService jwtService;
private final TenantService tenantService;
public ForwardAuthController(JwtService jwtService, TenantService tenantService) {
this.jwtService = jwtService;
this.tenantService = tenantService;
}
@GetMapping("/auth/verify")
public ResponseEntity<Void> verify(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
return ResponseEntity.status(401).build();
}
String token = authHeader.substring(7);
if (jwtService.isTokenValid(token)) {
String email = jwtService.extractEmail(token);
var userId = jwtService.extractUserId(token);
return ResponseEntity.ok()
.header("X-User-Id", userId.toString())
.header("X-User-Email", email)
.build();
}
return ResponseEntity.status(401).build();
}
}

View File

@@ -1,27 +1,71 @@
package net.siegeln.cameleer.saas.config;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
@Component
public class JwtConfig {
private static final Logger log = LoggerFactory.getLogger(JwtConfig.class);
@Value("${cameleer.jwt.expiration:86400}")
private long expirationSeconds = 86400;
@Value("${cameleer.jwt.private-key-path:}")
private String privateKeyPath = "";
@Value("${cameleer.jwt.public-key-path:}")
private String publicKeyPath = "";
private KeyPair keyPair;
@PostConstruct
public void init() throws NoSuchAlgorithmException {
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("Ed25519");
this.keyPair = keyGen.generateKeyPair();
public void init() throws NoSuchAlgorithmException, IOException, InvalidKeySpecException {
if (privateKeyPath.isEmpty() || publicKeyPath.isEmpty()) {
log.warn("No Ed25519 key files configured — generating ephemeral keys (dev mode)");
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("Ed25519");
this.keyPair = keyGen.generateKeyPair();
} else {
log.info("Loading Ed25519 keys from {} and {}", privateKeyPath, publicKeyPath);
PrivateKey privateKey = loadPrivateKey(Path.of(privateKeyPath));
PublicKey publicKey = loadPublicKey(Path.of(publicKeyPath));
this.keyPair = new KeyPair(publicKey, privateKey);
}
}
private PrivateKey loadPrivateKey(Path path) throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
String pem = Files.readString(path)
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s+", "");
byte[] decoded = Base64.getDecoder().decode(pem);
return KeyFactory.getInstance("Ed25519").generatePrivate(new PKCS8EncodedKeySpec(decoded));
}
private PublicKey loadPublicKey(Path path) throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
String pem = Files.readString(path)
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replaceAll("\\s+", "");
byte[] decoded = Base64.getDecoder().decode(pem);
return KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(decoded));
}
public PrivateKey getPrivateKey() {

View File

@@ -3,12 +3,14 @@ package net.siegeln.cameleer.saas.config;
import net.siegeln.cameleer.saas.auth.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@@ -17,23 +19,41 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
@EnableMethodSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final JwtAuthenticationFilter machineTokenFilter;
private final TenantResolutionFilter tenantResolutionFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
public SecurityConfig(JwtAuthenticationFilter machineTokenFilter, TenantResolutionFilter tenantResolutionFilter) {
this.machineTokenFilter = machineTokenFilter;
this.tenantResolutionFilter = tenantResolutionFilter;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
@Order(1)
public SecurityFilterChain machineAuthFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/agent/**", "/api/license/verify/**")
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.addFilterBefore(machineTokenFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/auth/verify").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> {}))
.addFilterBefore(machineTokenFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(tenantResolutionFilter, BearerTokenAuthenticationFilter.class);
return http.build();
}

View File

@@ -0,0 +1,22 @@
package net.siegeln.cameleer.saas.config;
import java.util.UUID;
public final class TenantContext {
private static final ThreadLocal<UUID> CURRENT_TENANT = new ThreadLocal<>();
private TenantContext() {}
public static UUID getTenantId() {
return CURRENT_TENANT.get();
}
public static void setTenantId(UUID tenantId) {
CURRENT_TENANT.set(tenantId);
}
public static void clear() {
CURRENT_TENANT.remove();
}
}

View File

@@ -0,0 +1,47 @@
package net.siegeln.cameleer.saas.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import net.siegeln.cameleer.saas.tenant.TenantService;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
public class TenantResolutionFilter extends OncePerRequestFilter {
private final TenantService tenantService;
public TenantResolutionFilter(TenantService tenantService) {
this.tenantService = tenantService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
var authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication instanceof JwtAuthenticationToken jwtAuth) {
Jwt jwt = jwtAuth.getToken();
String orgId = jwt.getClaimAsString("organization_id");
if (orgId != null) {
tenantService.getByLogtoOrgId(orgId)
.ifPresent(tenant -> TenantContext.setTenantId(tenant.getId()));
}
}
filterChain.doFilter(request, response);
} finally {
TenantContext.clear();
}
}
}

View File

@@ -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()
);
}
}

View File

@@ -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; }
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,5 @@
package net.siegeln.cameleer.saas.deployment;
public enum DesiredStatus {
RUNNING, STOPPED
}

View File

@@ -0,0 +1,5 @@
package net.siegeln.cameleer.saas.deployment;
public enum ObservedStatus {
BUILDING, STARTING, RUNNING, FAILED, STOPPED
}

View File

@@ -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
) {}

View File

@@ -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()
);
}
}

View File

@@ -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; }
}

View File

@@ -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);
}

View File

@@ -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");
}
}
}

View File

@@ -0,0 +1,5 @@
package net.siegeln.cameleer.saas.environment;
public enum EnvironmentStatus {
ACTIVE, SUSPENDED
}

View File

@@ -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
) {}

View File

@@ -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
) {}

View File

@@ -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
) {}

View File

@@ -0,0 +1,25 @@
package net.siegeln.cameleer.saas.identity;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
@Configuration
public class LogtoConfig {
@Value("${cameleer.identity.logto-endpoint:}")
private String logtoEndpoint;
@Value("${cameleer.identity.m2m-client-id:}")
private String m2mClientId;
@Value("${cameleer.identity.m2m-client-secret:}")
private String m2mClientSecret;
public String getLogtoEndpoint() { return logtoEndpoint; }
public String getM2mClientId() { return m2mClientId; }
public String getM2mClientSecret() { return m2mClientSecret; }
public boolean isConfigured() {
return !logtoEndpoint.isEmpty() && !m2mClientId.isEmpty() && !m2mClientSecret.isEmpty();
}
}

View File

@@ -0,0 +1,103 @@
package net.siegeln.cameleer.saas.identity;
import com.fasterxml.jackson.databind.JsonNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.time.Instant;
import java.util.Map;
@Service
public class LogtoManagementClient {
private static final Logger log = LoggerFactory.getLogger(LogtoManagementClient.class);
private final LogtoConfig config;
private final RestClient restClient;
private String cachedToken;
private Instant tokenExpiry = Instant.MIN;
public LogtoManagementClient(LogtoConfig config) {
this.config = config;
this.restClient = RestClient.builder()
.defaultHeader("Content-Type", "application/json")
.build();
}
public boolean isAvailable() {
return config.isConfigured();
}
public String createOrganization(String name, String description) {
if (!isAvailable()) {
log.warn("Logto not configured — skipping organization creation for '{}'", name);
return null;
}
var body = Map.of("name", name, "description", description != null ? description : "");
var response = restClient.post()
.uri(config.getLogtoEndpoint() + "/api/organizations")
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
.body(JsonNode.class);
return response != null ? response.get("id").asText() : null;
}
public void addUserToOrganization(String orgId, String userId) {
if (!isAvailable()) return;
restClient.post()
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId + "/users")
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("userIds", new String[]{userId}))
.retrieve()
.toBodilessEntity();
}
public void deleteOrganization(String orgId) {
if (!isAvailable()) return;
restClient.delete()
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId)
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.toBodilessEntity();
}
private synchronized String getAccessToken() {
if (cachedToken != null && Instant.now().isBefore(tokenExpiry.minusSeconds(60))) {
return cachedToken;
}
try {
var response = restClient.post()
.uri(config.getLogtoEndpoint() + "/oidc/token")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body("grant_type=client_credentials"
+ "&client_id=" + config.getM2mClientId()
+ "&client_secret=" + config.getM2mClientSecret()
+ "&resource=" + config.getLogtoEndpoint() + "/api"
+ "&scope=all")
.retrieve()
.body(JsonNode.class);
cachedToken = response.get("access_token").asText();
long expiresIn = response.get("expires_in").asLong();
tokenExpiry = Instant.now().plusSeconds(expiresIn);
return cachedToken;
} catch (Exception e) {
log.error("Failed to get Logto Management API token", e);
throw new RuntimeException("Logto authentication failed", e);
}
}
}

View File

@@ -0,0 +1,67 @@
package net.siegeln.cameleer.saas.license;
import net.siegeln.cameleer.saas.license.dto.LicenseResponse;
import net.siegeln.cameleer.saas.tenant.TenantService;
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.time.Duration;
import java.util.UUID;
@RestController
@RequestMapping("/api/tenants/{tenantId}/license")
public class LicenseController {
private final LicenseService licenseService;
private final TenantService tenantService;
public LicenseController(LicenseService licenseService, TenantService tenantService) {
this.licenseService = licenseService;
this.tenantService = tenantService;
}
@PostMapping
public ResponseEntity<LicenseResponse> generate(@PathVariable UUID tenantId,
Authentication authentication) {
var tenant = tenantService.getById(tenantId).orElse(null);
if (tenant == null) return ResponseEntity.notFound().build();
// Extract actor ID from JWT subject (Logto OIDC: sub may be a non-UUID string)
String sub = authentication.getName();
UUID actorId;
try {
actorId = UUID.fromString(sub);
} catch (IllegalArgumentException e) {
actorId = UUID.nameUUIDFromBytes(sub.getBytes());
}
var license = licenseService.generateLicense(tenant, Duration.ofDays(365), actorId);
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(license));
}
@GetMapping
public ResponseEntity<LicenseResponse> getActive(@PathVariable UUID tenantId) {
return licenseService.getActiveLicense(tenantId)
.map(entity -> ResponseEntity.ok(toResponse(entity)))
.orElse(ResponseEntity.notFound().build());
}
private LicenseResponse toResponse(LicenseEntity entity) {
return new LicenseResponse(
entity.getId(),
entity.getTenantId(),
entity.getTier(),
entity.getFeatures(),
entity.getLimits(),
entity.getIssuedAt(),
entity.getExpiresAt(),
entity.getToken()
);
}
}

View File

@@ -0,0 +1,44 @@
package net.siegeln.cameleer.saas.license;
import net.siegeln.cameleer.saas.tenant.Tier;
import java.util.Map;
public final class LicenseDefaults {
private LicenseDefaults() {}
public static Map<String, Object> featuresForTier(Tier tier) {
return switch (tier) {
case LOW -> Map.of(
"topology", true, "lineage", false,
"correlation", false, "debugger", false, "replay", false);
case MID -> Map.of(
"topology", true, "lineage", true,
"correlation", true, "debugger", false, "replay", false);
case HIGH -> Map.of(
"topology", true, "lineage", true,
"correlation", true, "debugger", true, "replay", true);
case BUSINESS -> Map.of(
"topology", true, "lineage", true,
"correlation", true, "debugger", true, "replay", true);
};
}
public static Map<String, Object> limitsForTier(Tier tier) {
return switch (tier) {
case LOW -> Map.of(
"max_agents", 3, "retention_days", 7,
"max_environments", 1);
case MID -> Map.of(
"max_agents", 10, "retention_days", 30,
"max_environments", 2);
case HIGH -> Map.of(
"max_agents", 50, "retention_days", 90,
"max_environments", -1);
case BUSINESS -> Map.of(
"max_agents", -1, "retention_days", 365,
"max_environments", -1);
};
}
}

View File

@@ -0,0 +1,78 @@
package net.siegeln.cameleer.saas.license;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
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 = "licenses")
public class LicenseEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "tenant_id", nullable = false)
private UUID tenantId;
@Column(name = "tier", nullable = false, length = 20)
private String tier;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "features", nullable = false, columnDefinition = "jsonb")
private Map<String, Object> features;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "limits", nullable = false, columnDefinition = "jsonb")
private Map<String, Object> limits;
@Column(name = "issued_at", nullable = false)
private Instant issuedAt;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(name = "revoked_at")
private Instant revokedAt;
@Column(name = "token", nullable = false, columnDefinition = "text")
private String token;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@PrePersist
protected void onCreate() {
if (createdAt == null) createdAt = Instant.now();
if (issuedAt == null) issuedAt = Instant.now();
}
public UUID getId() { return id; }
public UUID getTenantId() { return tenantId; }
public void setTenantId(UUID tenantId) { this.tenantId = tenantId; }
public String getTier() { return tier; }
public void setTier(String tier) { this.tier = tier; }
public Map<String, Object> getFeatures() { return features; }
public void setFeatures(Map<String, Object> features) { this.features = features; }
public Map<String, Object> getLimits() { return limits; }
public void setLimits(Map<String, Object> limits) { this.limits = limits; }
public Instant getIssuedAt() { return issuedAt; }
public void setIssuedAt(Instant issuedAt) { this.issuedAt = issuedAt; }
public Instant getExpiresAt() { return expiresAt; }
public void setExpiresAt(Instant expiresAt) { this.expiresAt = expiresAt; }
public Instant getRevokedAt() { return revokedAt; }
public void setRevokedAt(Instant revokedAt) { this.revokedAt = revokedAt; }
public String getToken() { return token; }
public void setToken(String token) { this.token = token; }
public Instant getCreatedAt() { return createdAt; }
}

View File

@@ -0,0 +1,14 @@
package net.siegeln.cameleer.saas.license;
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 LicenseRepository extends JpaRepository<LicenseEntity, UUID> {
List<LicenseEntity> findByTenantIdOrderByCreatedAtDesc(UUID tenantId);
Optional<LicenseEntity> findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(UUID tenantId);
}

View File

@@ -0,0 +1,121 @@
package net.siegeln.cameleer.saas.license;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.config.JwtConfig;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import org.springframework.stereotype.Service;
import java.security.Signature;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@Service
public class LicenseService {
private final LicenseRepository licenseRepository;
private final JwtConfig jwtConfig;
private final AuditService auditService;
private final ObjectMapper objectMapper = new ObjectMapper();
public LicenseService(LicenseRepository licenseRepository, JwtConfig jwtConfig, AuditService auditService) {
this.licenseRepository = licenseRepository;
this.jwtConfig = jwtConfig;
this.auditService = auditService;
}
public LicenseEntity generateLicense(TenantEntity tenant, Duration validity, UUID actorId) {
var features = LicenseDefaults.featuresForTier(tenant.getTier());
var limits = LicenseDefaults.limitsForTier(tenant.getTier());
Instant now = Instant.now();
Instant expiresAt = now.plus(validity);
String token = signLicenseJwt(tenant.getId(), tenant.getTier().name(), features, limits, now, expiresAt);
var entity = new LicenseEntity();
entity.setTenantId(tenant.getId());
entity.setTier(tenant.getTier().name());
entity.setFeatures(features);
entity.setLimits(limits);
entity.setIssuedAt(now);
entity.setExpiresAt(expiresAt);
entity.setToken(token);
var saved = licenseRepository.save(entity);
auditService.log(actorId, null, tenant.getId(),
AuditAction.LICENSE_GENERATE, saved.getId().toString(),
null, null, "SUCCESS", null);
return saved;
}
public Optional<LicenseEntity> getActiveLicense(UUID tenantId) {
return licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId);
}
public Optional<Map<String, Object>> verifyLicenseToken(String token) {
try {
String[] parts = token.split("\\.");
if (parts.length != 3) return Optional.empty();
String signingInput = parts[0] + "." + parts[1];
byte[] signatureBytes = Base64.getUrlDecoder().decode(parts[2]);
Signature sig = Signature.getInstance("Ed25519");
sig.initVerify(jwtConfig.getPublicKey());
sig.update(signingInput.getBytes(java.nio.charset.StandardCharsets.UTF_8));
if (!sig.verify(signatureBytes)) return Optional.empty();
byte[] payloadBytes = Base64.getUrlDecoder().decode(parts[1]);
Map<String, Object> payload = objectMapper.readValue(payloadBytes, new TypeReference<>() {});
long exp = ((Number) payload.get("exp")).longValue();
if (Instant.now().getEpochSecond() >= exp) return Optional.empty();
return Optional.of(payload);
} catch (Exception e) {
return Optional.empty();
}
}
private String signLicenseJwt(UUID tenantId, String tier, Map<String, Object> features,
Map<String, Object> limits, Instant issuedAt, Instant expiresAt) {
try {
String header = base64UrlEncode(objectMapper.writeValueAsBytes(
Map.of("alg", "EdDSA", "typ", "JWT", "kid", "license")));
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("tenant_id", tenantId.toString());
payload.put("tier", tier);
payload.put("features", features);
payload.put("limits", limits);
payload.put("iat", issuedAt.getEpochSecond());
payload.put("exp", expiresAt.getEpochSecond());
String payloadEncoded = base64UrlEncode(objectMapper.writeValueAsBytes(payload));
String signingInput = header + "." + payloadEncoded;
Signature sig = Signature.getInstance("Ed25519");
sig.initSign(jwtConfig.getPrivateKey());
sig.update(signingInput.getBytes(java.nio.charset.StandardCharsets.UTF_8));
String signature = base64UrlEncode(sig.sign());
return signingInput + "." + signature;
} catch (Exception e) {
throw new RuntimeException("Failed to sign license JWT", e);
}
}
private String base64UrlEncode(byte[] data) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(data);
}
}

View File

@@ -0,0 +1,16 @@
package net.siegeln.cameleer.saas.license.dto;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
public record LicenseResponse(
UUID id,
UUID tenantId,
String tier,
Map<String, Object> features,
Map<String, Object> limits,
Instant issuedAt,
Instant expiresAt,
String token
) {}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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
) {}

View File

@@ -0,0 +1,9 @@
package net.siegeln.cameleer.saas.runtime;
import java.nio.file.Path;
public record BuildImageRequest(
String baseImage,
Path jarPath,
String imageTag
) {}

View File

@@ -0,0 +1,8 @@
package net.siegeln.cameleer.saas.runtime;
public record ContainerStatus(
String state,
boolean running,
int exitCode,
String error
) {}

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,6 @@
package net.siegeln.cameleer.saas.runtime;
@FunctionalInterface
public interface LogConsumer {
void accept(String stream, String message, long timestampMillis);
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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
) {}

View File

@@ -0,0 +1,73 @@
package net.siegeln.cameleer.saas.tenant;
import jakarta.validation.Valid;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
import net.siegeln.cameleer.saas.tenant.dto.TenantResponse;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
@RestController
@RequestMapping("/api/tenants")
public class TenantController {
private final TenantService tenantService;
public TenantController(TenantService tenantService) {
this.tenantService = tenantService;
}
@PostMapping
public ResponseEntity<TenantResponse> create(@Valid @RequestBody CreateTenantRequest request,
Authentication authentication) {
try {
// Extract actor ID from JWT subject (Logto OIDC: sub may be a non-UUID string)
String sub = authentication.getName();
UUID actorId;
try {
actorId = UUID.fromString(sub);
} catch (IllegalArgumentException e) {
actorId = UUID.nameUUIDFromBytes(sub.getBytes());
}
var entity = tenantService.create(request, actorId);
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(entity));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
}
@GetMapping("/{id}")
public ResponseEntity<TenantResponse> getById(@PathVariable UUID id) {
return tenantService.getById(id)
.map(entity -> ResponseEntity.ok(toResponse(entity)))
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/by-slug/{slug}")
public ResponseEntity<TenantResponse> getBySlug(@PathVariable String slug) {
return tenantService.getBySlug(slug)
.map(entity -> ResponseEntity.ok(toResponse(entity)))
.orElse(ResponseEntity.notFound().build());
}
private TenantResponse toResponse(TenantEntity entity) {
return new TenantResponse(
entity.getId(),
entity.getName(),
entity.getSlug(),
entity.getTier().name(),
entity.getStatus().name(),
entity.getCreatedAt(),
entity.getUpdatedAt()
);
}
}

View File

@@ -0,0 +1,92 @@
package net.siegeln.cameleer.saas.tenant;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
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 = "tenants")
public class TenantEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "name", nullable = false)
private String name;
@Column(name = "slug", nullable = false, unique = true, length = 100)
private String slug;
@Enumerated(EnumType.STRING)
@Column(name = "tier", nullable = false, length = 20)
private Tier tier = Tier.LOW;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private TenantStatus status = TenantStatus.PROVISIONING;
@Column(name = "logto_org_id")
private String logtoOrgId;
@Column(name = "stripe_customer_id")
private String stripeCustomerId;
@Column(name = "stripe_subscription_id")
private String stripeSubscriptionId;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "settings", columnDefinition = "jsonb")
private Map<String, Object> settings = Map.of();
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@PrePersist
protected void onCreate() {
Instant now = Instant.now();
if (createdAt == null) createdAt = now;
if (updatedAt == null) updatedAt = now;
}
@PreUpdate
protected void onUpdate() {
updatedAt = Instant.now();
}
public UUID getId() { return id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getSlug() { return slug; }
public void setSlug(String slug) { this.slug = slug; }
public Tier getTier() { return tier; }
public void setTier(Tier tier) { this.tier = tier; }
public TenantStatus getStatus() { return status; }
public void setStatus(TenantStatus status) { this.status = status; }
public String getLogtoOrgId() { return logtoOrgId; }
public void setLogtoOrgId(String logtoOrgId) { this.logtoOrgId = logtoOrgId; }
public String getStripeCustomerId() { return stripeCustomerId; }
public void setStripeCustomerId(String stripeCustomerId) { this.stripeCustomerId = stripeCustomerId; }
public String getStripeSubscriptionId() { return stripeSubscriptionId; }
public void setStripeSubscriptionId(String stripeSubscriptionId) { this.stripeSubscriptionId = stripeSubscriptionId; }
public Map<String, Object> getSettings() { return settings; }
public void setSettings(Map<String, Object> settings) { this.settings = settings; }
public Instant getCreatedAt() { return createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
}

View File

@@ -0,0 +1,16 @@
package net.siegeln.cameleer.saas.tenant;
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 TenantRepository extends JpaRepository<TenantEntity, UUID> {
Optional<TenantEntity> findBySlug(String slug);
Optional<TenantEntity> findByLogtoOrgId(String logtoOrgId);
List<TenantEntity> findByStatus(TenantStatus status);
boolean existsBySlug(String slug);
}

View File

@@ -0,0 +1,100 @@
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;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Service
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, EnvironmentService environmentService) {
this.tenantRepository = tenantRepository;
this.auditService = auditService;
this.logtoClient = logtoClient;
this.environmentService = environmentService;
}
public TenantEntity create(CreateTenantRequest request, UUID actorId) {
if (tenantRepository.existsBySlug(request.slug())) {
throw new IllegalArgumentException("Slug already taken");
}
var entity = new TenantEntity();
entity.setName(request.name());
entity.setSlug(request.slug());
entity.setTier(request.tier() != null ? Tier.valueOf(request.tier()) : Tier.LOW);
entity.setStatus(TenantStatus.PROVISIONING);
var saved = tenantRepository.save(entity);
if (logtoClient.isAvailable()) {
String logtoOrgId = logtoClient.createOrganization(saved.getName(), "Tenant: " + saved.getSlug());
if (logtoOrgId != null) {
saved.setLogtoOrgId(logtoOrgId);
saved = tenantRepository.save(saved);
}
}
environmentService.createDefaultForTenant(saved.getId());
auditService.log(actorId, null, saved.getId(),
AuditAction.TENANT_CREATE, saved.getSlug(),
null, null, "SUCCESS", null);
return saved;
}
public Optional<TenantEntity> getById(UUID id) {
return tenantRepository.findById(id);
}
public Optional<TenantEntity> getBySlug(String slug) {
return tenantRepository.findBySlug(slug);
}
public Optional<TenantEntity> getByLogtoOrgId(String logtoOrgId) {
return tenantRepository.findByLogtoOrgId(logtoOrgId);
}
public List<TenantEntity> listActive() {
return tenantRepository.findByStatus(TenantStatus.ACTIVE);
}
public TenantEntity activate(UUID tenantId, UUID actorId) {
var entity = tenantRepository.findById(tenantId)
.orElseThrow(() -> new IllegalArgumentException("Tenant not found"));
entity.setStatus(TenantStatus.ACTIVE);
var saved = tenantRepository.save(entity);
auditService.log(actorId, null, tenantId,
AuditAction.TENANT_UPDATE, entity.getSlug(),
null, null, "SUCCESS", null);
return saved;
}
public TenantEntity suspend(UUID tenantId, UUID actorId) {
var entity = tenantRepository.findById(tenantId)
.orElseThrow(() -> new IllegalArgumentException("Tenant not found"));
entity.setStatus(TenantStatus.SUSPENDED);
var saved = tenantRepository.save(entity);
auditService.log(actorId, null, tenantId,
AuditAction.TENANT_SUSPEND, entity.getSlug(),
null, null, "SUCCESS", null);
return saved;
}
}

View File

@@ -0,0 +1,5 @@
package net.siegeln.cameleer.saas.tenant;
public enum TenantStatus {
PROVISIONING, ACTIVE, SUSPENDED, DELETED
}

View File

@@ -0,0 +1,5 @@
package net.siegeln.cameleer.saas.tenant;
public enum Tier {
LOW, MID, HIGH, BUSINESS
}

View File

@@ -0,0 +1,11 @@
package net.siegeln.cameleer.saas.tenant.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public record CreateTenantRequest(
@NotBlank @Size(max = 255) String name,
@NotBlank @Size(max = 100) @Pattern(regexp = "^[a-z0-9][a-z0-9-]*[a-z0-9]$", message = "Slug must be lowercase alphanumeric with hyphens") String slug,
String tier
) {}

View File

@@ -0,0 +1,14 @@
package net.siegeln.cameleer.saas.tenant.dto;
import java.time.Instant;
import java.util.UUID;
public record TenantResponse(
UUID id,
String name,
String slug,
String tier,
String status,
Instant createdAt,
Instant updatedAt
) {}

View File

@@ -3,3 +3,8 @@ spring:
show-sql: false
flyway:
clean-disabled: false
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://test-issuer.example.com/oidc

View File

@@ -8,6 +8,12 @@ spring:
flyway:
enabled: true
locations: classpath:db/migration
security:
oauth2:
resourceserver:
jwt:
issuer-uri: ${LOGTO_ISSUER_URI:}
jwk-set-uri: ${LOGTO_JWK_SET_URI:}
management:
endpoints:
@@ -21,3 +27,23 @@ management:
cameleer:
jwt:
expiration: 86400 # 24 hours in seconds
private-key-path: ${CAMELEER_JWT_PRIVATE_KEY_PATH:}
public-key-path: ${CAMELEER_JWT_PUBLIC_KEY_PATH:}
identity:
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}

View File

@@ -0,0 +1,17 @@
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) NOT NULL UNIQUE,
tier VARCHAR(20) NOT NULL DEFAULT 'LOW',
status VARCHAR(20) NOT NULL DEFAULT 'PROVISIONING',
logto_org_id VARCHAR(255),
stripe_customer_id VARCHAR(255),
stripe_subscription_id VARCHAR(255),
settings JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_tenants_slug ON tenants (slug);
CREATE INDEX idx_tenants_status ON tenants (status);
CREATE INDEX idx_tenants_logto_org_id ON tenants (logto_org_id);

View File

@@ -0,0 +1,15 @@
CREATE TABLE licenses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
tier VARCHAR(20) NOT NULL,
features JSONB NOT NULL DEFAULT '{}',
limits JSONB NOT NULL DEFAULT '{}',
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
token TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_licenses_tenant_id ON licenses (tenant_id);
CREATE INDEX idx_licenses_expires_at ON licenses (expires_at);

View File

@@ -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);

View 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);

View 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);

View File

@@ -6,7 +6,7 @@ import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
@SpringBootTest
@Import(TestcontainersConfig.class)
@Import({TestcontainersConfig.class, TestSecurityConfig.class})
@ActiveProfiles("test")
class CameleerSaasApplicationTest {

View File

@@ -0,0 +1,24 @@
package net.siegeln.cameleer.saas;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import java.time.Instant;
import java.util.Map;
@TestConfiguration
public class TestSecurityConfig {
@Bean
public JwtDecoder jwtDecoder() {
return token -> Jwt.withTokenValue(token)
.header("alg", "RS256")
.claim("sub", "test-user")
.claim("iss", "https://test-issuer.example.com/oidc")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(3600))
.build();
}
}

View File

@@ -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());
}
}

View 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");
}
}

View File

@@ -1,125 +0,0 @@
package net.siegeln.cameleer.saas.auth;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.TestcontainersConfig;
import net.siegeln.cameleer.saas.auth.dto.LoginRequest;
import net.siegeln.cameleer.saas.auth.dto.RegisterRequest;
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 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)
@ActiveProfiles("test")
class AuthControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
void register_returns201WithToken() throws Exception {
var request = new RegisterRequest("newuser@example.com", "New User", "password123");
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.token").isNotEmpty())
.andExpect(jsonPath("$.email").value("newuser@example.com"))
.andExpect(jsonPath("$.name").value("New User"));
}
@Test
void register_returns409ForDuplicateEmail() throws Exception {
var request = new RegisterRequest("duplicate@example.com", "User One", "password123");
// First registration
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated());
// Duplicate registration
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isConflict());
}
@Test
void login_returns200WithToken() throws Exception {
var registerRequest = new RegisterRequest("loginuser@example.com", "Login User", "password123");
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(registerRequest)))
.andExpect(status().isCreated());
var loginRequest = new LoginRequest("loginuser@example.com", "password123");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(loginRequest)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").isNotEmpty())
.andExpect(jsonPath("$.email").value("loginuser@example.com"));
}
@Test
void login_returns401ForBadPassword() throws Exception {
var registerRequest = new RegisterRequest("badpass@example.com", "Bad Pass", "password123");
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(registerRequest)))
.andExpect(status().isCreated());
var loginRequest = new LoginRequest("badpass@example.com", "wrong-password");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(loginRequest)))
.andExpect(status().isUnauthorized());
}
@Test
void protectedEndpoint_returns401WithoutToken() throws Exception {
mockMvc.perform(get("/api/health/secured"))
.andExpect(status().isUnauthorized());
}
@Test
void protectedEndpoint_returns200WithValidToken() throws Exception {
// Register to get a token
var registerRequest = new RegisterRequest("secured@example.com", "Secured User", "password123");
var result = mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(registerRequest)))
.andExpect(status().isCreated())
.andReturn();
var responseBody = objectMapper.readTree(result.getResponse().getContentAsString());
String token = responseBody.get("token").asText();
// Access protected endpoint with token
mockMvc.perform(get("/api/health/secured")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("authenticated"));
}
}

View File

@@ -1,171 +0,0 @@
package net.siegeln.cameleer.saas.auth;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.auth.dto.LoginRequest;
import net.siegeln.cameleer.saas.auth.dto.RegisterRequest;
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.springframework.security.crypto.password.PasswordEncoder;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AuthServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private RoleRepository roleRepository;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private JwtService jwtService;
@Mock
private AuditService auditService;
private AuthService authService;
@BeforeEach
void setUp() {
authService = new AuthService(userRepository, roleRepository,
passwordEncoder, jwtService, auditService);
}
@Test
void register_createsUserAndReturnsToken() {
var request = new RegisterRequest("user@example.com", "Test User", "password123");
var ownerRole = new RoleEntity();
ownerRole.setName("OWNER");
when(userRepository.existsByEmail("user@example.com")).thenReturn(false);
when(passwordEncoder.encode("password123")).thenReturn("encoded-password");
when(roleRepository.findByName("OWNER")).thenReturn(Optional.of(ownerRole));
when(userRepository.save(any(UserEntity.class))).thenAnswer(invocation -> {
UserEntity user = invocation.getArgument(0);
// simulate ID assignment by persistence
try {
var idField = UserEntity.class.getDeclaredField("id");
idField.setAccessible(true);
idField.set(user, java.util.UUID.randomUUID());
} catch (Exception e) {
throw new RuntimeException(e);
}
return user;
});
when(jwtService.generateToken(any(UserEntity.class))).thenReturn("test-jwt-token");
var response = authService.register(request, "127.0.0.1");
assertNotNull(response);
assertEquals("test-jwt-token", response.token());
assertEquals("user@example.com", response.email());
assertEquals("Test User", response.name());
// Verify audit was logged
verify(auditService).log(
any(), eq("user@example.com"), eq(null),
eq(AuditAction.AUTH_REGISTER), eq(null),
eq(null), eq("127.0.0.1"),
eq("SUCCESS"), eq(null)
);
}
@Test
void register_rejectsDuplicateEmail() {
var request = new RegisterRequest("existing@example.com", "Test User", "password123");
when(userRepository.existsByEmail("existing@example.com")).thenReturn(true);
var exception = assertThrows(IllegalArgumentException.class,
() -> authService.register(request, "127.0.0.1"));
assertEquals("Email already registered", exception.getMessage());
verify(userRepository, never()).save(any());
verify(auditService, never()).log(any(), any(), any(), any(), any(), any(), any(), any(), any());
}
@Test
void login_returnsTokenForValidCredentials() {
var request = new LoginRequest("user@example.com", "password123");
var user = createUserWithId("user@example.com", "encoded-password");
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
when(passwordEncoder.matches("password123", "encoded-password")).thenReturn(true);
when(jwtService.generateToken(user)).thenReturn("login-jwt-token");
var response = authService.login(request, "192.168.1.1");
assertNotNull(response);
assertEquals("login-jwt-token", response.token());
assertEquals("user@example.com", response.email());
verify(auditService).log(
any(), eq("user@example.com"), eq(null),
eq(AuditAction.AUTH_LOGIN), eq(null),
eq(null), eq("192.168.1.1"),
eq("SUCCESS"), eq(null)
);
}
@Test
void login_rejectsInvalidPassword() {
var request = new LoginRequest("user@example.com", "wrong-password");
var user = createUserWithId("user@example.com", "encoded-password");
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
when(passwordEncoder.matches("wrong-password", "encoded-password")).thenReturn(false);
assertThrows(IllegalArgumentException.class,
() -> authService.login(request, "192.168.1.1"));
// Verify AUTH_LOGIN_FAILED audit was logged
verify(auditService).log(
any(), eq("user@example.com"), eq(null),
eq(AuditAction.AUTH_LOGIN_FAILED), eq(null),
eq(null), eq("192.168.1.1"),
eq("FAILURE"), eq(null)
);
}
@Test
void login_rejectsUnknownEmail() {
var request = new LoginRequest("unknown@example.com", "password123");
when(userRepository.findByEmail("unknown@example.com")).thenReturn(Optional.empty());
var exception = assertThrows(IllegalArgumentException.class,
() -> authService.login(request, "192.168.1.1"));
assertEquals("Invalid credentials", exception.getMessage());
verify(auditService, never()).log(any(), any(), any(), any(), any(), any(), any(), any(), any());
}
private UserEntity createUserWithId(String email, String password) {
var user = new UserEntity();
user.setEmail(email);
user.setName("Test User");
user.setPassword(password);
try {
var idField = UserEntity.class.getDeclaredField("id");
idField.setAccessible(true);
idField.set(user, UUID.randomUUID());
} catch (Exception e) {
throw new RuntimeException(e);
}
return user;
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}

View File

@@ -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());
}
}

View File

@@ -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");
}
}

View File

@@ -0,0 +1,82 @@
package net.siegeln.cameleer.saas.license;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.TestcontainersConfig;
import net.siegeln.cameleer.saas.TestSecurityConfig;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
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 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 LicenseControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
private String createTenantAndGetId() throws Exception {
String slug = "license-tenant-" + System.nanoTime();
var request = new CreateTenantRequest("License Test Org", slug, "MID");
var result = mockMvc.perform(post("/api/tenants")
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andReturn();
return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
}
@Test
void generateLicense_returns201WithToken() throws Exception {
String tenantId = createTenantAndGetId();
mockMvc.perform(post("/api/tenants/" + tenantId + "/license")
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.token").isNotEmpty())
.andExpect(jsonPath("$.tier").value("MID"))
.andExpect(jsonPath("$.features.correlation").value(true));
}
@Test
void getActiveLicense_returnsLicense() throws Exception {
String tenantId = createTenantAndGetId();
mockMvc.perform(post("/api/tenants/" + tenantId + "/license")
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.andExpect(status().isCreated());
mockMvc.perform(get("/api/tenants/" + tenantId + "/license")
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.tier").value("MID"));
}
@Test
void getActiveLicense_returns404WhenNone() throws Exception {
String tenantId = createTenantAndGetId();
mockMvc.perform(get("/api/tenants/" + tenantId + "/license")
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.andExpect(status().isNotFound());
}
}

View File

@@ -0,0 +1,142 @@
package net.siegeln.cameleer.saas.license;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.config.JwtConfig;
import net.siegeln.cameleer.saas.tenant.Tier;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import net.siegeln.cameleer.saas.tenant.TenantStatus;
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.time.Duration;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class LicenseServiceTest {
@Mock
private LicenseRepository licenseRepository;
@Mock
private AuditService auditService;
private JwtConfig jwtConfig;
private LicenseService licenseService;
@BeforeEach
void setUp() throws Exception {
jwtConfig = new JwtConfig();
jwtConfig.init(); // generates ephemeral keys for testing
licenseService = new LicenseService(licenseRepository, jwtConfig, auditService);
}
private TenantEntity createTenant(Tier tier) {
var tenant = new TenantEntity();
tenant.setName("Test Tenant");
tenant.setSlug("test");
tenant.setTier(tier);
tenant.setStatus(TenantStatus.ACTIVE);
try {
var idField = TenantEntity.class.getDeclaredField("id");
idField.setAccessible(true);
idField.set(tenant, UUID.randomUUID());
} catch (Exception e) {
throw new RuntimeException(e);
}
return tenant;
}
private static LicenseEntity withGeneratedId(LicenseEntity entity) {
try {
var idField = LicenseEntity.class.getDeclaredField("id");
idField.setAccessible(true);
idField.set(entity, UUID.randomUUID());
} catch (Exception e) {
throw new RuntimeException(e);
}
return entity;
}
@Test
void generateLicense_producesValidSignedToken() {
var tenant = createTenant(Tier.MID);
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
var license = licenseService.generateLicense(tenant, Duration.ofDays(365), UUID.randomUUID());
assertThat(license.getToken()).isNotBlank();
assertThat(license.getToken().split("\\.")).hasSize(3);
assertThat(license.getTier()).isEqualTo("MID");
}
@Test
void generateLicense_setsCorrectFeaturesForTier() {
var tenant = createTenant(Tier.HIGH);
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());
assertThat(license.getFeatures()).containsEntry("debugger", true);
assertThat(license.getFeatures()).containsEntry("replay", true);
}
@Test
void generateLicense_setsCorrectLimitsForTier() {
var tenant = createTenant(Tier.LOW);
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());
assertThat(license.getLimits()).containsEntry("max_agents", 3);
assertThat(license.getLimits()).containsEntry("retention_days", 7);
}
@Test
void verifyLicenseToken_validTokenReturnsPayload() {
var tenant = createTenant(Tier.MID);
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());
var payload = licenseService.verifyLicenseToken(license.getToken());
assertThat(payload).isPresent();
assertThat(payload.get().get("tier")).isEqualTo("MID");
assertThat(payload.get().get("tenant_id")).isEqualTo(tenant.getId().toString());
}
@Test
void verifyLicenseToken_tamperedTokenReturnsEmpty() {
var tenant = createTenant(Tier.MID);
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());
String tampered = license.getToken() + "x";
var payload = licenseService.verifyLicenseToken(tampered);
assertThat(payload).isEmpty();
}
@Test
void generateLicense_logsAuditEvent() {
var tenant = createTenant(Tier.LOW);
var actorId = UUID.randomUUID();
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
licenseService.generateLicense(tenant, Duration.ofDays(30), actorId);
var actionCaptor = ArgumentCaptor.forClass(AuditAction.class);
verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any());
assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.LICENSE_GENERATE);
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,97 @@
package net.siegeln.cameleer.saas.tenant;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.TestcontainersConfig;
import net.siegeln.cameleer.saas.TestSecurityConfig;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
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 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 TenantControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
void createTenant_returns201() throws Exception {
var request = new CreateTenantRequest("Test Org", "test-org-" + System.nanoTime(), "LOW");
mockMvc.perform(post("/api/tenants")
.with(jwt().jwt(j -> j
.claim("sub", "test-user")
.claim("organization_id", "test-org")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name").value("Test Org"))
.andExpect(jsonPath("$.tier").value("LOW"))
.andExpect(jsonPath("$.status").value("PROVISIONING"));
}
@Test
void createTenant_returns409ForDuplicateSlug() throws Exception {
String slug = "duplicate-slug-" + System.nanoTime();
var request = new CreateTenantRequest("First", slug, null);
mockMvc.perform(post("/api/tenants")
.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")
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isConflict());
}
@Test
void createTenant_returns401WithoutToken() throws Exception {
var request = new CreateTenantRequest("Test", "no-auth-test", null);
mockMvc.perform(post("/api/tenants")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isUnauthorized());
}
@Test
void getTenant_returnsTenantById() throws Exception {
String slug = "get-test-" + System.nanoTime();
var request = new CreateTenantRequest("Get Test", slug, null);
var createResult = mockMvc.perform(post("/api/tenants")
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andReturn();
String id = objectMapper.readTree(createResult.getResponse().getContentAsString()).get("id").asText();
mockMvc.perform(get("/api/tenants/" + id)
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.slug").value(slug));
}
}

View File

@@ -0,0 +1,128 @@
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;
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.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 TenantServiceTest {
@Mock
private TenantRepository tenantRepository;
@Mock
private AuditService auditService;
@Mock
private LogtoManagementClient logtoClient;
@Mock
private EnvironmentService environmentService;
private TenantService tenantService;
@BeforeEach
void setUp() {
tenantService = new TenantService(tenantRepository, auditService, logtoClient, environmentService);
}
@Test
void create_savesNewTenantWithCorrectFields() {
var request = new CreateTenantRequest("Acme Corp", "acme-corp", "MID");
var actorId = UUID.randomUUID();
when(tenantRepository.existsBySlug("acme-corp")).thenReturn(false);
when(tenantRepository.save(any(TenantEntity.class))).thenAnswer(inv -> inv.getArgument(0));
var result = tenantService.create(request, actorId);
assertThat(result.getName()).isEqualTo("Acme Corp");
assertThat(result.getSlug()).isEqualTo("acme-corp");
assertThat(result.getTier()).isEqualTo(Tier.MID);
assertThat(result.getStatus()).isEqualTo(TenantStatus.PROVISIONING);
}
@Test
void create_throwsForDuplicateSlug() {
var request = new CreateTenantRequest("Acme Corp", "acme-corp", null);
when(tenantRepository.existsBySlug("acme-corp")).thenReturn(true);
assertThatThrownBy(() -> tenantService.create(request, UUID.randomUUID()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Slug already taken");
}
@Test
void create_logsAuditEvent() {
var request = new CreateTenantRequest("Acme Corp", "acme-corp", null);
var actorId = UUID.randomUUID();
when(tenantRepository.existsBySlug("acme-corp")).thenReturn(false);
when(tenantRepository.save(any(TenantEntity.class))).thenAnswer(inv -> inv.getArgument(0));
tenantService.create(request, actorId);
var actionCaptor = ArgumentCaptor.forClass(AuditAction.class);
verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any());
assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.TENANT_CREATE);
}
@Test
void create_defaultsToLowTier() {
var request = new CreateTenantRequest("Acme Corp", "acme-corp", null);
when(tenantRepository.existsBySlug("acme-corp")).thenReturn(false);
when(tenantRepository.save(any(TenantEntity.class))).thenAnswer(inv -> inv.getArgument(0));
var result = tenantService.create(request, UUID.randomUUID());
assertThat(result.getTier()).isEqualTo(Tier.LOW);
}
@Test
void getById_returnsTenant() {
var id = UUID.randomUUID();
var entity = new TenantEntity();
entity.setName("Test");
entity.setSlug("test");
when(tenantRepository.findById(id)).thenReturn(Optional.of(entity));
var result = tenantService.getById(id);
assertThat(result).isPresent();
assertThat(result.get().getName()).isEqualTo("Test");
}
@Test
void getBySlug_returnsTenant() {
var entity = new TenantEntity();
entity.setName("Test");
entity.setSlug("test");
when(tenantRepository.findBySlug("test")).thenReturn(Optional.of(entity));
var result = tenantService.getBySlug("test");
assertThat(result).isPresent();
assertThat(result.get().getSlug()).isEqualTo("test");
}
}

14
traefik.yml Normal file
View File

@@ -0,0 +1,14 @@
api:
dashboard: false
entryPoints:
web:
address: ":80"
websecure:
address: ":443"
providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
network: cameleer