Compare commits
324 Commits
0ba896ada4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
292adeea4c | ||
|
|
43a1058f33 | ||
|
|
60a800f757 | ||
|
|
76a62135ab | ||
|
|
17ba02c30d | ||
|
|
9b898924ab | ||
|
|
8de16019b7 | ||
|
|
ad2b16f26d | ||
|
|
2007a4b2da | ||
|
|
9057479da7 | ||
|
|
89c83ec7b8 | ||
|
|
b3104dc410 | ||
|
|
5bf94c6d4e | ||
|
|
40daca36a0 | ||
|
|
8c9edfdb55 | ||
|
|
25f4afcddc | ||
|
|
02be1d9264 | ||
|
|
cc7c87a520 | ||
|
|
ca19faf4f0 | ||
|
|
b86cc812b7 | ||
|
|
f0dda0d2ee | ||
|
|
3cd6bd5585 | ||
|
|
25d66af45e | ||
|
|
d783040030 | ||
|
|
6afc337b16 | ||
|
|
e881e302b6 | ||
|
|
d7ef2c488b | ||
|
|
088bc34e67 | ||
|
|
73e41e5607 | ||
|
|
f5b68c212b | ||
|
|
7c82ba93b0 | ||
|
|
1066101e8a | ||
|
|
ffb7ef0839 | ||
|
|
4dea1c6764 | ||
|
|
6c3f21d4db | ||
|
|
7a8960ca46 | ||
|
|
fdc7187424 | ||
|
|
2fd14165bc | ||
|
|
13bd03997a | ||
|
|
e64bf4f0d1 | ||
|
|
883e10ba7c | ||
|
|
0413a5b882 | ||
|
|
c6b6bafc0f | ||
|
|
988035b952 | ||
|
|
c55427c22b | ||
|
|
f681784e7e | ||
|
|
7b57ee8246 | ||
|
|
6e6e4218c9 | ||
|
|
469b36613b | ||
|
|
bcb8a040f4 | ||
|
|
d52084a081 | ||
|
|
7e7407b137 | ||
|
|
0a77080bca | ||
|
|
a5b30cd1ea | ||
|
|
ffb65edcec | ||
|
|
8b8909e488 | ||
|
|
94de4c2a5b | ||
|
|
66477ff575 | ||
|
|
6c70efcb54 | ||
|
|
1f3a9551c5 | ||
|
|
08a3ad03b7 | ||
|
|
cfcf852e2d | ||
|
|
67f7d634c9 | ||
|
|
6f984c6b78 | ||
|
|
5754b0ca81 | ||
|
|
484a388b62 | ||
|
|
d720c0500f | ||
|
|
cfa9d41b36 | ||
|
|
b974f233f4 | ||
|
|
3741ac2658 | ||
|
|
e8a726af80 | ||
|
|
53f0e55e93 | ||
|
|
06d114b46b | ||
|
|
171ed1a6ab | ||
|
|
dee1f39554 | ||
|
|
adb4ef1af8 | ||
|
|
4cc3e096b5 | ||
|
|
1d26ae481e | ||
|
|
8fe18c7f83 | ||
|
|
929e7d5aed | ||
|
|
3ab6408258 | ||
|
|
f0aa2b7d3a | ||
|
|
9bf6c17d63 | ||
|
|
1a4ae5b49b | ||
|
|
400c32a539 | ||
|
|
2cb818ec71 | ||
|
|
37668dcfe0 | ||
|
|
40ea6e5e69 | ||
|
|
6ab0a3c5a1 | ||
|
|
8130f2053d | ||
|
|
9da908e4d2 | ||
|
|
d0dba73a29 | ||
|
|
9aa535ace8 | ||
|
|
f85b5a3634 | ||
|
|
39e1b39f7a | ||
|
|
283d3e34a0 | ||
|
|
2cd15509ba | ||
|
|
9d87f71bc1 | ||
|
|
6b77a96d52 | ||
|
|
c58bf90604 | ||
|
|
273baf7996 | ||
|
|
5ca118dc93 | ||
|
|
0b8cdf6dd9 | ||
|
|
cafd7e9369 | ||
|
|
b5068250f9 | ||
|
|
0cfa359fc5 | ||
|
|
5cc9f8c9ef | ||
|
|
b066d1abe7 | ||
|
|
ae1d9fa4db | ||
|
|
6fe10432e6 | ||
|
|
9f3faf4816 | ||
|
|
a60095608e | ||
|
|
9f9112c6a5 | ||
|
|
e1a9f6d225 | ||
|
|
180644f0df | ||
|
|
62b74d2d06 | ||
|
|
3e2f035d97 | ||
|
|
9962ee99d9 | ||
|
|
b53840b77b | ||
|
|
9ed2cedc98 | ||
|
|
dc7ac3a1ec | ||
|
|
1fbafbb16d | ||
|
|
6c1241ed89 | ||
|
|
df64573bfb | ||
|
|
4526d97bda | ||
|
|
132143c083 | ||
|
|
b824942408 | ||
|
|
31e8dd05f0 | ||
|
|
eba9f560ac | ||
|
|
3c2bf4a9b1 | ||
|
|
97b2235914 | ||
|
|
338db5dcda | ||
|
|
fd50a147a2 | ||
|
|
0dd52624b7 | ||
|
|
1ce0ea411d | ||
|
|
81be25198c | ||
|
|
dc4ea33c9b | ||
|
|
186f7639ad | ||
|
|
6c7895b0d6 | ||
|
|
6170f61eeb | ||
|
|
2ed527ac74 | ||
|
|
cb1f6b8ccf | ||
|
|
758585cc9a | ||
|
|
141b44048c | ||
|
|
3c343f9441 | ||
|
|
bdb24f8de6 | ||
|
|
933b56f68f | ||
|
|
19c463051a | ||
|
|
41052d01e8 | ||
|
|
99e75b0a4e | ||
|
|
eb6897bf10 | ||
|
|
63c194dab7 | ||
|
|
44a0e413e9 | ||
|
|
15306dddc0 | ||
|
|
6eb848f353 | ||
|
|
d53afe43cc | ||
|
|
24a443ef30 | ||
|
|
d7eb700860 | ||
|
|
c1458e4995 | ||
|
|
b79a7fe405 | ||
|
|
6d6c1f3562 | ||
|
|
0e3f383cf4 | ||
|
|
cd6dd1e5af | ||
|
|
dfa2a6bfa2 | ||
|
|
a7196ff4c1 | ||
|
|
17c6723f7e | ||
|
|
91e93696ed | ||
|
|
57e41e407c | ||
|
|
bc46af5cea | ||
|
|
03fb414981 | ||
|
|
553ecc1490 | ||
|
|
dec1c53d30 | ||
|
|
ace6ad0cf2 | ||
|
|
4a67677158 | ||
|
|
27c3f4d136 | ||
|
|
fe6682e520 | ||
|
|
012c866594 | ||
|
|
4e553a6c42 | ||
|
|
f254f2700f | ||
|
|
17d8d98d5f | ||
|
|
bfb26d9aa5 | ||
|
|
cd4266ffc6 | ||
|
|
74a1e02cb8 | ||
|
|
b3a19098c5 | ||
|
|
6b1dcba876 | ||
|
|
38125f9ecc | ||
|
|
6b95cf78ea | ||
|
|
b70d95cbb9 | ||
|
|
8b9045b0e2 | ||
|
|
4fe642b91d | ||
|
|
7e13b4ee5d | ||
|
|
85eabd86ef | ||
|
|
b44f6338f8 | ||
|
|
4ff04c386e | ||
|
|
b38f02eae3 | ||
|
|
8c504b714d | ||
|
|
83801d2499 | ||
|
|
9042356e81 | ||
|
|
f97e951d87 | ||
|
|
fa6bca0add | ||
|
|
11dd6a354f | ||
|
|
7f15177310 | ||
|
|
b01f6e5109 | ||
|
|
8146f072df | ||
|
|
f13fd3faf0 | ||
|
|
5e5bc97bf5 | ||
|
|
7fc80cad58 | ||
|
|
6eabd0cf2e | ||
|
|
4debee966a | ||
|
|
1e348eb8ca | ||
|
|
f136502a35 | ||
|
|
bf367b1db7 | ||
|
|
f5165add13 | ||
|
|
ec38d0b1c2 | ||
|
|
6cd82de5f9 | ||
|
|
0a0898b2f7 | ||
|
|
6864081550 | ||
|
|
fe5838b40f | ||
|
|
1b57f03973 | ||
|
|
0a06615ae2 | ||
|
|
16a2ff3174 | ||
|
|
c2ccf9d233 | ||
|
|
06c85edd8e | ||
|
|
9514ab69c8 | ||
|
|
d3a9be8f2e | ||
|
|
85e0d6156a | ||
|
|
96aa6579b0 | ||
|
|
da4a263cd7 | ||
|
|
879accfc7f | ||
|
|
35a62463b3 | ||
|
|
92503a1061 | ||
|
|
95a92ae9e5 | ||
|
|
5aa8586940 | ||
|
|
776a01d87b | ||
|
|
0b736a92f9 | ||
|
|
df90814cc3 | ||
|
|
8cf44f6e2c | ||
|
|
5e69628a51 | ||
|
|
9163f919c8 | ||
|
|
3b8b76d53e | ||
|
|
e5523c969e | ||
|
|
e2e5c794a2 | ||
|
|
d5eead888d | ||
|
|
4121bd64b2 | ||
|
|
dd8553a8b4 | ||
|
|
3284304c1f | ||
|
|
6f8b84fb1a | ||
|
|
d2caa737b9 | ||
|
|
875b07fb3a | ||
|
|
4fdf171912 | ||
|
|
2239d3d980 | ||
|
|
8eef7e170b | ||
|
|
d7ce0aaf8c | ||
|
|
a0c12b8ee6 | ||
|
|
a5445e332e | ||
|
|
cab6e409b9 | ||
|
|
0fe084bcb2 | ||
|
|
3ae8fa18cd | ||
|
|
82f62ca0ff | ||
|
|
dd30ee77d4 | ||
|
|
a3a6f99958 | ||
|
|
22752ffcb1 | ||
|
|
a48c4bfd08 | ||
|
|
45bcc954ac | ||
|
|
51a1aef10e | ||
|
|
2607ef5dbe | ||
|
|
0a1e848ef7 | ||
|
|
6dc5e558a3 | ||
|
|
a3a1643b37 | ||
|
|
4447d79c92 | ||
|
|
7e7a07470b | ||
|
|
252c18bcff | ||
|
|
269c679e9c | ||
|
|
e559267f1e | ||
|
|
4341656a5e | ||
|
|
2cda065c06 | ||
|
|
bcad83cc40 | ||
|
|
0d47c2ec7c | ||
|
|
247ec030e5 | ||
|
|
a1acc0bc62 | ||
|
|
8b94937d38 | ||
|
|
1750fe64a2 | ||
|
|
4572a4bb57 | ||
|
|
9824d06824 | ||
|
|
e24c6da025 | ||
|
|
6bdcbf840b | ||
|
|
4699db5465 | ||
|
|
d911fd2201 | ||
|
|
b4f9277220 | ||
|
|
eaf109549d | ||
|
|
3a6b94c1eb | ||
|
|
b727bc771d | ||
|
|
7ee2985626 | ||
|
|
3efae43879 | ||
|
|
aa663a9c9e | ||
|
|
f5ef8e6488 | ||
|
|
0a43a7dcd1 | ||
|
|
3b345881c6 | ||
|
|
2dc75c4361 | ||
|
|
b7a0530466 | ||
|
|
ebdb4f9450 | ||
|
|
5ed33807d8 | ||
|
|
00476c974f | ||
|
|
c674785c82 | ||
|
|
4087ce8f29 | ||
|
|
39c3b39711 | ||
|
|
cdd495d985 | ||
|
|
17fbe73e60 | ||
|
|
faac0048c3 | ||
|
|
e6f2f17fa1 | ||
|
|
28d044efbc | ||
|
|
6a81053d37 | ||
|
|
fd41a056eb | ||
|
|
9ecaf22f09 | ||
|
|
d2f6b02a5f | ||
|
|
bf3aa57274 | ||
|
|
e56e3fca8a | ||
|
|
127834ce4d | ||
|
|
6bdb02ff5a | ||
|
|
96a5b1d9f1 | ||
|
|
771e9d1081 | ||
|
|
ebba021448 | ||
|
|
81d570fd63 | ||
|
|
7b92de4017 |
65
.env.example
@@ -1,32 +1,55 @@
|
||||
# Cameleer SaaS Environment Variables
|
||||
# Copy to .env and fill in values
|
||||
# Cameleer SaaS — Environment Configuration
|
||||
# Copy to .env and fill in values for production
|
||||
|
||||
# Application version
|
||||
# Image version
|
||||
VERSION=latest
|
||||
|
||||
# Public access
|
||||
PUBLIC_HOST=localhost
|
||||
PUBLIC_PROTOCOL=https
|
||||
# Auth domain (Logto). Defaults to PUBLIC_HOST for single-domain setups.
|
||||
# Set to a separate subdomain (e.g. auth.cameleer.io) to split auth from the app.
|
||||
# AUTH_HOST=localhost
|
||||
|
||||
# Ports
|
||||
HTTP_PORT=80
|
||||
HTTPS_PORT=443
|
||||
LOGTO_CONSOLE_PORT=3002
|
||||
|
||||
# PostgreSQL
|
||||
POSTGRES_USER=cameleer
|
||||
POSTGRES_PASSWORD=change_me_in_production
|
||||
POSTGRES_DB=cameleer_saas
|
||||
|
||||
# Logto Identity Provider
|
||||
LOGTO_ENDPOINT=http://logto:3001
|
||||
LOGTO_PUBLIC_ENDPOINT=http://localhost:3001
|
||||
LOGTO_ISSUER_URI=http://localhost: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=
|
||||
LOGTO_SPA_CLIENT_ID=
|
||||
# ClickHouse
|
||||
CLICKHOUSE_PASSWORD=change_me_in_production
|
||||
|
||||
# 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
|
||||
# Admin user (created by bootstrap)
|
||||
# In SaaS mode, this must be an email address (primary user identity).
|
||||
# In standalone mode, any username is accepted.
|
||||
SAAS_ADMIN_USER=admin@example.com
|
||||
SAAS_ADMIN_PASS=change_me_in_production
|
||||
|
||||
# Domain (for Traefik TLS)
|
||||
DOMAIN=localhost
|
||||
# SMTP / email connector configuration is managed at runtime via the vendor
|
||||
# admin UI (Email Connector page at /vendor/email). No SMTP env vars needed.
|
||||
|
||||
CAMELEER_AUTH_TOKEN=change_me_bootstrap_token
|
||||
CAMELEER_CONTAINER_MEMORY_LIMIT=512m
|
||||
CAMELEER_CONTAINER_CPU_SHARES=512
|
||||
CAMELEER_TENANT_SLUG=default
|
||||
# TLS (leave empty for self-signed)
|
||||
# NODE_TLS_REJECT=0 # Set to 1 when using real certificates
|
||||
# CERT_FILE=
|
||||
# KEY_FILE=
|
||||
# CA_FILE=
|
||||
|
||||
# Vendor account (optional)
|
||||
VENDOR_SEED_ENABLED=false
|
||||
# VENDOR_USER=vendor
|
||||
# VENDOR_PASS=change_me
|
||||
|
||||
# Docker socket GID (run: stat -c '%g' /var/run/docker.sock)
|
||||
# DOCKER_GID=0
|
||||
|
||||
# Docker images (override for custom registries)
|
||||
# TRAEFIK_IMAGE=gitea.siegeln.net/cameleer/cameleer-traefik
|
||||
# POSTGRES_IMAGE=gitea.siegeln.net/cameleer/cameleer-postgres
|
||||
# CLICKHOUSE_IMAGE=gitea.siegeln.net/cameleer/cameleer-clickhouse
|
||||
# LOGTO_IMAGE=gitea.siegeln.net/cameleer/cameleer-logto
|
||||
# CAMELEER_IMAGE=gitea.siegeln.net/cameleer/cameleer-saas
|
||||
|
||||
@@ -39,8 +39,8 @@ jobs:
|
||||
|
||||
- name: Build and Test (unit tests only)
|
||||
run: >-
|
||||
mvn clean verify -B
|
||||
-Dsurefire.excludes="**/AuthControllerTest.java,**/TenantControllerTest.java,**/LicenseControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java,**/EnvironmentControllerTest.java,**/AppControllerTest.java,**/DeploymentControllerTest.java,**/AgentStatusControllerTest.java"
|
||||
mvn clean verify -U -B
|
||||
-Dsurefire.excludes="**/AuthControllerTest.java,**/TenantControllerTest.java,**/LicenseControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java,**/EnvironmentControllerTest.java,**/AppControllerTest.java,**/DeploymentControllerTest.java,**/AgentStatusControllerTest.java,**/VendorTenantControllerTest.java,**/TenantPortalControllerTest.java"
|
||||
|
||||
- name: Build sign-in UI
|
||||
run: |
|
||||
@@ -111,12 +111,17 @@ jobs:
|
||||
|
||||
- name: Build and push runtime base image
|
||||
run: |
|
||||
AGENT_VERSION=$(curl -sf "https://gitea.siegeln.net/api/packages/cameleer/maven/com/cameleer3/cameleer3-agent/1.0-SNAPSHOT/maven-metadata.xml" \
|
||||
AGENT_VERSION=$(curl -sf "https://gitea.siegeln.net/api/packages/cameleer/maven/com/cameleer/cameleer-agent/1.0-SNAPSHOT/maven-metadata.xml" \
|
||||
| sed -n 's/.*<value>\([^<]*\)<\/value>.*/\1/p' | tail -1)
|
||||
echo "Agent version: $AGENT_VERSION"
|
||||
curl -sf -o docker/runtime-base/agent.jar \
|
||||
"https://gitea.siegeln.net/api/packages/cameleer/maven/com/cameleer3/cameleer3-agent/1.0-SNAPSHOT/cameleer3-agent-${AGENT_VERSION}-shaded.jar"
|
||||
ls -la docker/runtime-base/agent.jar
|
||||
"https://gitea.siegeln.net/api/packages/cameleer/maven/com/cameleer/cameleer-agent/1.0-SNAPSHOT/cameleer-agent-${AGENT_VERSION}-shaded.jar"
|
||||
APPENDER_VERSION=$(curl -sf "https://gitea.siegeln.net/api/packages/cameleer/maven/com/cameleer/cameleer-log-appender/1.0-SNAPSHOT/maven-metadata.xml" \
|
||||
| sed -n 's/.*<value>\([^<]*\)<\/value>.*/\1/p' | tail -1)
|
||||
echo "Log appender version: $APPENDER_VERSION"
|
||||
curl -sf -o docker/runtime-base/cameleer-log-appender.jar \
|
||||
"https://gitea.siegeln.net/api/packages/cameleer/maven/com/cameleer/cameleer-log-appender/1.0-SNAPSHOT/cameleer-log-appender-${APPENDER_VERSION}.jar"
|
||||
ls -la docker/runtime-base/agent.jar docker/runtime-base/cameleer-log-appender.jar
|
||||
TAGS="-t gitea.siegeln.net/cameleer/cameleer-runtime-base:${{ github.sha }}"
|
||||
for TAG in $IMAGE_TAGS; do
|
||||
TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer-runtime-base:$TAG"
|
||||
@@ -139,6 +144,39 @@ jobs:
|
||||
--cache-from type=registry,ref=gitea.siegeln.net/cameleer/cameleer-logto:buildcache \
|
||||
--cache-to type=registry,ref=gitea.siegeln.net/cameleer/cameleer-logto:buildcache,mode=max \
|
||||
--provenance=false \
|
||||
--push ui/sign-in/
|
||||
--push .
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and push PostgreSQL image
|
||||
run: |
|
||||
TAGS="-t gitea.siegeln.net/cameleer/cameleer-postgres:${{ github.sha }}"
|
||||
for TAG in $IMAGE_TAGS; do
|
||||
TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer-postgres:$TAG"
|
||||
done
|
||||
docker buildx build --platform linux/amd64 \
|
||||
$TAGS \
|
||||
--provenance=false \
|
||||
--push docker/cameleer-postgres/
|
||||
|
||||
- name: Build and push ClickHouse image
|
||||
run: |
|
||||
TAGS="-t gitea.siegeln.net/cameleer/cameleer-clickhouse:${{ github.sha }}"
|
||||
for TAG in $IMAGE_TAGS; do
|
||||
TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer-clickhouse:$TAG"
|
||||
done
|
||||
docker buildx build --platform linux/amd64 \
|
||||
$TAGS \
|
||||
--provenance=false \
|
||||
--push docker/cameleer-clickhouse/
|
||||
|
||||
- name: Build and push Traefik image
|
||||
run: |
|
||||
TAGS="-t gitea.siegeln.net/cameleer/cameleer-traefik:${{ github.sha }}"
|
||||
for TAG in $IMAGE_TAGS; do
|
||||
TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer-traefik:$TAG"
|
||||
done
|
||||
docker buildx build --platform linux/amd64 \
|
||||
$TAGS \
|
||||
--provenance=false \
|
||||
--push docker/cameleer-traefik/
|
||||
|
||||
9
.gitignore
vendored
@@ -22,6 +22,15 @@ Thumbs.db
|
||||
# Worktrees
|
||||
.worktrees/
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
.superpowers/
|
||||
.playwright-mcp/
|
||||
.gitnexus
|
||||
|
||||
# Installer output (generated by install.sh / install.ps1)
|
||||
installer/cameleer/
|
||||
|
||||
# Generated by postinstall from @cameleer/design-system
|
||||
ui/public/favicon.svg
|
||||
docker/runtime-base/agent.jar
|
||||
|
||||
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "installer"]
|
||||
path = installer
|
||||
url = https://gitea.siegeln.net/cameleer/cameleer-saas-installer.git
|
||||
101
AGENTS.md
Normal file
@@ -0,0 +1,101 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **cameleer-saas** (3336 symbols, 7094 relationships, 281 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
|
||||
## When Debugging
|
||||
|
||||
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||
3. `READ gitnexus://repo/cameleer-saas/process/{processName}` — trace the full execution flow step by step
|
||||
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||
|
||||
## When Refactoring
|
||||
|
||||
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||
|
||||
## Tools Quick Reference
|
||||
|
||||
| Tool | When to use | Command |
|
||||
|------|-------------|---------|
|
||||
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||
|
||||
## Impact Risk Levels
|
||||
|
||||
| Depth | Meaning | Action |
|
||||
|-------|---------|--------|
|
||||
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/cameleer-saas/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/cameleer-saas/clusters` | All functional areas |
|
||||
| `gitnexus://repo/cameleer-saas/processes` | All execution flows |
|
||||
| `gitnexus://repo/cameleer-saas/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## Self-Check Before Finishing
|
||||
|
||||
Before completing any code modification task, verify:
|
||||
1. `gitnexus_impact` was run for all modified symbols
|
||||
2. No HIGH/CRITICAL risk warnings were ignored
|
||||
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||
4. All d=1 (WILL BREAK) dependents were updated
|
||||
|
||||
## Keeping the Index Fresh
|
||||
|
||||
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze
|
||||
```
|
||||
|
||||
If the index previously included embeddings, preserve them by adding `--embeddings`:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze --embeddings
|
||||
```
|
||||
|
||||
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
|
||||
|
||||
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
|
||||
|
||||
## CLI
|
||||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
320
CLAUDE.md
@@ -4,206 +4,60 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project
|
||||
|
||||
Cameleer SaaS — multi-tenant SaaS platform wrapping the Cameleer observability stack (Java agent + server) for Apache Camel applications. Customers get managed observability for their Camel integrations without running infrastructure.
|
||||
Cameleer SaaS — **vendor management plane** for the Cameleer observability stack. Three personas: **vendor** (platform:admin) manages the platform and provisions tenants; **tenant admin** (tenant:manage) manages their observability instance; **new user** (authenticated, no scopes) goes through self-service onboarding. Tenants can be created by the vendor OR via self-service sign-up (email registration + onboarding wizard). Each tenant gets per-tenant cameleer-server + UI instances via Docker API.
|
||||
|
||||
**Email is the primary user identity** in SaaS mode. `SAAS_ADMIN_USER` IS the email address — there is no separate `SAAS_ADMIN_EMAIL`. The installer enforces email format in SaaS mode (must contain `@`; auto-appends `@<PUBLIC_HOST>` if missing). The bootstrap uses `SAAS_ADMIN_USER` as both the Logto username and primaryEmail. In standalone mode, any username is accepted. Self-service registration (email + password + verification code) is disabled by default and enabled via the vendor UI after configuring an email connector.
|
||||
|
||||
## Ecosystem
|
||||
|
||||
This repo is the SaaS layer on top of two proven components:
|
||||
|
||||
- **cameleer3** (sibling repo) — Java agent using ByteBuddy for zero-code instrumentation of Camel apps. Captures route executions, processor traces, payloads, metrics, and route graph topology. Deploys as `-javaagent` JAR.
|
||||
- **cameleer3-server** (sibling repo) — Spring Boot observability backend. Receives agent data via HTTP, pushes config/commands via SSE. PostgreSQL + ClickHouse storage. React SPA dashboard. JWT auth with Ed25519 config signing. Docker container orchestration for app deployments.
|
||||
- **cameleer** (sibling repo) — Java agent using ByteBuddy for zero-code instrumentation of Camel apps. Captures route executions, processor traces, payloads, metrics, and route graph topology. Deploys as `-javaagent` JAR.
|
||||
- **cameleer-server** (sibling repo) — Spring Boot observability backend. Receives agent data via HTTP, pushes config/commands via SSE. PostgreSQL + ClickHouse storage. React SPA dashboard. JWT auth with Ed25519 config signing. Docker container orchestration for app deployments.
|
||||
- **cameleer-website** — Marketing site (Astro 5)
|
||||
- **design-system** — Shared React component library (`@cameleer/design-system` on Gitea npm registry)
|
||||
|
||||
Agent-server protocol is defined in `cameleer3/cameleer3-common/PROTOCOL.md`. The agent and server are mature, proven components — this repo wraps them with multi-tenancy, billing, and self-service onboarding.
|
||||
Agent-server protocol is defined in `cameleer/cameleer-common/PROTOCOL.md`. The agent and server are mature, proven components — this repo wraps them with multi-tenancy, billing, and self-service onboarding.
|
||||
|
||||
## Key Classes
|
||||
## Key Packages
|
||||
|
||||
### Java Backend (`src/main/java/net/siegeln/cameleer/saas/`)
|
||||
|
||||
**config/** — Security, tenant isolation, web config
|
||||
- `SecurityConfig.java` — OAuth2 JWT decoder (ES384, issuer/audience validation, scope extraction)
|
||||
- `TenantIsolationInterceptor.java` — HandlerInterceptor on `/api/**`; JWT org_id -> TenantContext, path variable validation, fail-closed
|
||||
- `TenantContext.java` — ThreadLocal<UUID> tenant ID storage
|
||||
- `WebConfig.java` — registers TenantIsolationInterceptor
|
||||
- `PublicConfigController.java` — GET /api/config (Logto endpoint, SPA client ID, scopes)
|
||||
- `MeController.java` — GET /api/me (authenticated user, tenant list)
|
||||
| Package | Purpose | Key classes |
|
||||
|---------|---------|-------------|
|
||||
| `config/` | Security, tenant isolation, web config | `SecurityConfig`, `TenantIsolationInterceptor`, `TenantContext`, `PublicConfigController`, `MeController` |
|
||||
| `tenant/` | Tenant data model | `TenantEntity` (JPA: id, name, slug, tier, status, logto_org_id, db_password) |
|
||||
| `vendor/` | Vendor console (platform:admin) | `VendorTenantService`, `VendorTenantController`, `InfrastructureService`, `EmailConnectorService`, `EmailConnectorController`, `VendorAuthPolicyController`, `VendorAuthPolicyEntity` |
|
||||
| `onboarding/` | Self-service sign-up onboarding | `OnboardingController`, `OnboardingService` |
|
||||
| `portal/` | Tenant admin portal (org-scoped) | `TenantPortalService`, `TenantPortalController` |
|
||||
| `provisioning/` | Pluggable tenant provisioning | `DockerTenantProvisioner`, `TenantDatabaseService`, `TenantDataCleanupService` |
|
||||
| `certificate/` | TLS certificate lifecycle | `CertificateService`, `CertificateController`, `TenantCaCertService` |
|
||||
| `license/` | License management | `LicenseService`, `LicenseController` |
|
||||
| `identity/` | Logto & server integration | `LogtoManagementClient`, `ServerApiClient` |
|
||||
| `audit/` | Audit logging | `AuditService` |
|
||||
|
||||
**tenant/** — Tenant lifecycle
|
||||
- `TenantEntity.java` — JPA entity (id, name, slug, tier, status, logto_org_id, stripe IDs, settings JSONB)
|
||||
- `TenantService.java` — create tenant -> Logto org, activate, suspend
|
||||
- `TenantController.java` — POST create, GET list, GET by ID
|
||||
### Frontend
|
||||
|
||||
**license/** — License management
|
||||
- `LicenseEntity.java` — JPA entity (id, tenant_id, tier, features JSONB, limits JSONB, expires_at)
|
||||
- `LicenseService.java` — generation, validation, feature/limit lookups
|
||||
- `LicenseController.java` — POST issue, GET verify, DELETE revoke
|
||||
|
||||
**identity/** — Logto & server integration
|
||||
- `LogtoConfig.java` — Logto endpoint, M2M credentials (reads from bootstrap file)
|
||||
- `LogtoManagementClient.java` — Logto Management API calls (create org, create user, add to org)
|
||||
- `ServerApiClient.java` — M2M client for cameleer3-server API (Logto M2M token, `X-Cameleer-Protocol-Version: 1` header)
|
||||
|
||||
**audit/** — Audit logging
|
||||
- `AuditEntity.java` — JPA entity (actor_id, tenant_id, action, resource, status)
|
||||
- `AuditService.java` — log audit events (TENANT_CREATE, TENANT_UPDATE, etc.)
|
||||
|
||||
### React Frontend (`ui/src/`)
|
||||
|
||||
- `main.tsx` — React 19 root
|
||||
- `router.tsx` — /login, /callback, / -> OrgResolver -> Layout -> pages
|
||||
- `config.ts` — fetch Logto config from /platform/api/config
|
||||
- `auth/useAuth.ts` — auth hook (isAuthenticated, logout, signIn)
|
||||
- `auth/useOrganization.ts` — Zustand store for current tenant
|
||||
- `auth/useScopes.ts` — decode JWT scopes, hasScope()
|
||||
- `auth/ProtectedRoute.tsx` — guard (redirects to /login)
|
||||
- `pages/DashboardPage.tsx` — tenant dashboard
|
||||
- `pages/LicensePage.tsx` — license info
|
||||
- `pages/AdminTenantsPage.tsx` — platform admin tenant management
|
||||
|
||||
### Custom Sign-in UI (`ui/sign-in/src/`)
|
||||
|
||||
- `SignInPage.tsx` — form with @cameleer/design-system components
|
||||
- `experience-api.ts` — Logto Experience API client (4-step: init -> verify -> identify -> submit)
|
||||
- **`ui/src/`** — React 19 SPA at `/platform/*` (vendor + tenant admin pages)
|
||||
- **`ui/sign-in/`** — Custom Logto sign-in UI (built into `cameleer-logto` Docker image)
|
||||
|
||||
## Architecture Context
|
||||
|
||||
The existing cameleer3-server already has single-tenant auth (JWT, RBAC, bootstrap tokens, OIDC). The SaaS layer must:
|
||||
- Add multi-tenancy (tenant isolation of agent data, diagrams, configs)
|
||||
- Provide self-service signup, billing, and team management
|
||||
- Generate per-tenant bootstrap tokens for agent registration
|
||||
- Proxy or federate access to tenant-specific cameleer3-server instances
|
||||
- Enforce usage quotas and metered billing
|
||||
The SaaS platform is a **vendor management plane**. It does not proxy requests to servers — instead it provisions dedicated per-tenant cameleer-server instances via Docker API. Each tenant gets isolated server + UI containers with their own database schemas, networks, and Traefik routing.
|
||||
|
||||
### Routing (single-domain, path-based via Traefik)
|
||||
|
||||
All services on one hostname. Two env vars control everything: `PUBLIC_HOST` + `PUBLIC_PROTOCOL`.
|
||||
|
||||
| Path | Target | Notes |
|
||||
|------|--------|-------|
|
||||
| `/platform/*` | cameleer-saas:8080 | SPA + API (`server.servlet.context-path: /platform`) |
|
||||
| `/server/*` | cameleer3-server-ui:80 | Server dashboard (strip-prefix + `BASE_PATH=/server`) |
|
||||
| `/` | redirect -> `/platform/` | Via `docker/traefik-dynamic.yml` |
|
||||
| `/*` (catch-all) | cameleer-logto:3001 (priority=1) | Custom sign-in UI, OIDC, interaction |
|
||||
|
||||
- SPA assets at `/_app/` (Vite `assetsDir: '_app'`) to avoid conflict with Logto's `/assets/`
|
||||
- Logto `ENDPOINT` = `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}` (same domain, same origin)
|
||||
- TLS: self-signed cert init container (`traefik-certs`) for dev, ACME for production
|
||||
- Root `/` -> `/platform/` redirect via Traefik file provider (`docker/traefik-dynamic.yml`)
|
||||
- LoginPage auto-redirects to Logto OIDC (no intermediate button)
|
||||
|
||||
### Docker Networks
|
||||
|
||||
Two networks in docker-compose.yml:
|
||||
|
||||
| Network | Name on Host | Purpose |
|
||||
|---------|-------------|---------|
|
||||
| `cameleer` | `cameleer-saas_cameleer` | Compose default — all services (DB, Logto, SaaS, server) |
|
||||
| `cameleer-traefik` | `cameleer-traefik` (fixed `name:`) | Traefik + server + deployed app containers |
|
||||
|
||||
The `cameleer-traefik` network uses `name: cameleer-traefik` (no compose project prefix) so `DockerNetworkManager.ensureNetwork("cameleer-traefik")` in the server finds it. The server joins with DNS alias `cameleer3-server`, matching `CAMELEER_SERVER_URL=http://cameleer3-server:8081`. Per-environment networks (`cameleer-env-{slug}`) are created dynamically by the server's `DockerNetworkManager`.
|
||||
|
||||
### Custom sign-in UI (`ui/sign-in/`)
|
||||
|
||||
Separate Vite+React SPA replacing Logto's default sign-in page. Visually matches cameleer3-server LoginPage.
|
||||
|
||||
- Built as custom Logto Docker image (`cameleer-logto`): `ui/sign-in/Dockerfile` = node build stage + `FROM ghcr.io/logto-io/logto:latest` + COPY dist over `/etc/logto/packages/experience/dist/`
|
||||
- Uses `@cameleer/design-system` components (Card, Input, Button, FormField, Alert)
|
||||
- Authenticates via Logto Experience API (4-step: init -> verify password -> identify -> submit -> redirect)
|
||||
- `CUSTOM_UI_PATH` env var does NOT work for Logto OSS — must volume-mount or replace the experience dist directory
|
||||
- Favicon bundled in `ui/sign-in/public/favicon.svg` (served by Logto, not SaaS)
|
||||
|
||||
### Auth enforcement
|
||||
|
||||
- All API endpoints enforce OAuth2 scopes via `@PreAuthorize("hasAuthority('SCOPE_xxx')")` annotations
|
||||
- Tenant isolation enforced by `TenantIsolationInterceptor` (a single `HandlerInterceptor` on `/api/**` that resolves JWT org_id to TenantContext and validates `{tenantId}`, `{environmentId}`, `{appId}` path variables; fail-closed, platform admins bypass)
|
||||
- 13 OAuth2 scopes on the Logto API resource (`https://api.cameleer.local`): 10 platform scopes + 3 server scopes (`server:admin`, `server:operator`, `server:viewer`), served to the frontend from `GET /platform/api/config`
|
||||
- Server scopes map to server RBAC roles via JWT `scope` claim (SaaS platform path) or `roles` claim (server-ui OIDC login path)
|
||||
- 4-role model: `saas-vendor` (global, hosted only), org `owner` -> `server:admin`, org `operator` -> `server:operator`, org `viewer` -> `server:viewer`
|
||||
- `saas-vendor` global role injected via `docker/vendor-seed.sh` (not standard bootstrap) — has `platform:admin` + all tenant scopes
|
||||
- Custom `JwtDecoder` in `SecurityConfig.java` — ES384 algorithm, `at+jwt` token type, split issuer-uri (string validation) / jwk-set-uri (Docker-internal fetch), audience validation (`https://api.cameleer.local`)
|
||||
- Logto Custom JWT (Phase 7b in bootstrap) injects a `roles` claim into access tokens based on org roles and global roles — this makes role data available to the server without Logto-specific code
|
||||
|
||||
### Server integration (cameleer3-server env vars)
|
||||
|
||||
| Env var | Value | Purpose |
|
||||
|---------|-------|---------|
|
||||
| `CAMELEER_OIDC_ISSUER_URI` | `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/oidc` | Token issuer claim validation |
|
||||
| `CAMELEER_OIDC_JWK_SET_URI` | `http://logto:3001/oidc/jwks` | Docker-internal JWK fetch |
|
||||
| `CAMELEER_OIDC_TLS_SKIP_VERIFY` | `true` | Skip cert verify for OIDC discovery (dev only — disable in production) |
|
||||
| `CAMELEER_CORS_ALLOWED_ORIGINS` | `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}` | Allow browser requests through Traefik |
|
||||
| `BASE_PATH` (server-ui) | `/server` | React Router basename + `<base>` tag |
|
||||
|
||||
### Server runtime env vars (docker-compose.dev.yml)
|
||||
|
||||
| Env var | Value | Purpose |
|
||||
|---------|-------|---------|
|
||||
| `CAMELEER_RUNTIME_ENABLED` | `true` | Enable Docker orchestration |
|
||||
| `CAMELEER_JAR_STORAGE_PATH` | `/data/jars` | Where JARs are stored inside server container |
|
||||
| `CAMELEER_RUNTIME_BASE_IMAGE` | `gitea.siegeln.net/cameleer/cameleer-runtime-base:latest` | Base image for deployed apps |
|
||||
| `CAMELEER_SERVER_URL` | `http://cameleer3-server:8081` | Server URL agents connect to |
|
||||
| `CAMELEER_ROUTING_DOMAIN` | `${PUBLIC_HOST}` | Domain for Traefik routing labels |
|
||||
| `CAMELEER_ROUTING_MODE` | `path` | `path` or `subdomain` routing |
|
||||
| `CAMELEER_JAR_DOCKER_VOLUME` | `cameleer-saas_jardata` | Named volume for Docker-in-Docker JAR mounting |
|
||||
|
||||
### Server OIDC role extraction (two paths)
|
||||
|
||||
| Path | Token type | Role source | How it works |
|
||||
|------|-----------|-------------|--------------|
|
||||
| SaaS platform -> server API | Logto org-scoped access token | `scope` claim | `JwtAuthenticationFilter.extractRolesFromScopes()` reads `server:admin` from scope |
|
||||
| Server-ui SSO login | Logto JWT access token (via Traditional Web App) | `roles` claim | `OidcTokenExchanger` decodes access_token, reads `roles` injected by Custom JWT |
|
||||
|
||||
The server's OIDC config (`OidcConfig`) includes `audience` (RFC 8707 resource indicator) and `additionalScopes`. The `audience` is sent as `resource` in both the authorization request and token exchange, which makes Logto return a JWT access token instead of opaque. The Custom JWT script maps org roles to `roles: ["server:admin"]`. If OIDC returns no roles and the user already exists, `syncOidcRoles` preserves existing local roles.
|
||||
|
||||
### Deployment pipeline
|
||||
|
||||
App deployment is handled by the cameleer3-server's `DeploymentExecutor` (7-stage async flow):
|
||||
1. PRE_FLIGHT — validate config, check JAR exists
|
||||
2. PULL_IMAGE — pull base image if missing
|
||||
3. CREATE_NETWORK — ensure cameleer-traefik and cameleer-env-{slug} networks
|
||||
4. START_REPLICAS — create N containers with Traefik labels
|
||||
5. HEALTH_CHECK — poll `/cameleer/health` on agent port 9464
|
||||
6. SWAP_TRAFFIC — stop old deployment (blue/green)
|
||||
7. COMPLETE — mark RUNNING or DEGRADED
|
||||
|
||||
Key files:
|
||||
- `DeploymentExecutor.java` (in cameleer3-server) — async staged deployment
|
||||
- `DockerRuntimeOrchestrator.java` (in cameleer3-server) — Docker client, container lifecycle
|
||||
- `docker/runtime-base/Dockerfile` — base image with agent JAR, maps env vars to `-D` system properties
|
||||
- `ServerApiClient.java` — M2M token acquisition for SaaS->server API calls (agent status). Uses `X-Cameleer-Protocol-Version: 1` header
|
||||
- Docker socket access: `group_add: ["0"]` in docker-compose.dev.yml (not root group membership in Dockerfile)
|
||||
- Network: deployed containers join `cameleer-traefik` (routing) + `cameleer-env-{slug}` (isolation)
|
||||
|
||||
### Bootstrap (`docker/logto-bootstrap.sh`)
|
||||
|
||||
Idempotent script run via `logto-bootstrap` init container. Phases:
|
||||
1. Wait for Logto + server health
|
||||
2. Get Management API token (reads `m-default` secret from DB)
|
||||
3. Create Logto apps (SPA, Traditional with `skipConsent`, M2M with Management API role + server API role)
|
||||
3b. Create API resource scopes (10 platform + 3 server scopes)
|
||||
4. Create org roles (owner, operator, viewer with API resource scope assignments) + M2M server role (`cameleer-m2m-server` with `server:admin` scope)
|
||||
5. Create users (platform owner with Logto console access, viewer for testing read-only OIDC)
|
||||
6. Create organization, add users with org roles (owner + viewer)
|
||||
7. Configure cameleer3-server OIDC (`rolesClaim: "roles"`, `audience`, `defaultRoles: ["VIEWER"]`)
|
||||
7b. Configure Logto Custom JWT for access tokens (maps org roles -> `roles` claim: admin->server:admin, member->server:viewer)
|
||||
8. Configure Logto sign-in branding (Cameleer colors `#C6820E`/`#D4941E`, logo from `/platform/logo.svg`)
|
||||
9. Cleanup seeded Logto apps
|
||||
10. Write bootstrap results to `/data/logto-bootstrap.json`
|
||||
|
||||
Platform owner credentials (`SAAS_ADMIN_USER`/`SAAS_ADMIN_PASS`) work for both the SaaS platform and the Logto console (port 3002). The `saas-vendor` global role (hosted only) is created separately via `docker/vendor-seed.sh`.
|
||||
For detailed architecture docs, see the directory-scoped CLAUDE.md files (loaded automatically when editing code in that directory):
|
||||
- **Provisioning flow, env vars, lifecycle** → `src/.../provisioning/CLAUDE.md`
|
||||
- **Auth, scopes, JWT, OIDC** → `src/.../config/CLAUDE.md`
|
||||
- **Docker, routing, networks, bootstrap, deployment pipeline** → `docker/CLAUDE.md`
|
||||
- **Installer, deployment modes, compose templates** → `installer/CLAUDE.md` (git submodule: `cameleer-saas-installer`)
|
||||
- **Frontend, sign-in UI** → `ui/CLAUDE.md`
|
||||
|
||||
## Database Migrations
|
||||
|
||||
PostgreSQL (Flyway): `src/main/resources/db/migration/`
|
||||
- V001 — tenants (id, name, slug, tier, status, logto_org_id, stripe IDs, settings JSONB)
|
||||
- V002 — licenses (id, tenant_id, tier, features JSONB, limits JSONB, expires_at)
|
||||
- V003 — environments (tenant -> environments 1:N)
|
||||
- V004 — api_keys (auth tokens for agent registration)
|
||||
- V005 — apps (Camel applications)
|
||||
- V006 — deployments (app versions, deployment history)
|
||||
- V007 — audit_log
|
||||
- V008 — app resource limits
|
||||
- V010 — cleanup of migrated tables
|
||||
- V001 — consolidated baseline: tenants (with db_password, server_endpoint, provision_error, ca_applied_at), licenses, audit_log, certificates, tenant_ca_certs
|
||||
- V002 — license minter: signing_keys table, tier renames, license label + grace period
|
||||
- V003 — passkey MFA: vendor_auth_policy single-row config table (mfa_mode, passkey_enabled, passkey_mode)
|
||||
|
||||
## Related Conventions
|
||||
|
||||
@@ -211,13 +65,117 @@ PostgreSQL (Flyway): `src/main/resources/db/migration/`
|
||||
- CI: `.gitea/workflows/` — Gitea Actions
|
||||
- K8s target: k3s cluster at 192.168.50.86
|
||||
- Docker images: CI builds and pushes all images — Dockerfiles use multi-stage builds, no local builds needed
|
||||
- `cameleer-saas` — SaaS app (frontend + JAR baked in)
|
||||
- `cameleer-saas` — SaaS vendor management plane (frontend + JAR baked in)
|
||||
- `cameleer-logto` — custom Logto with sign-in UI baked in
|
||||
- `cameleer-runtime-base` — base image for deployed apps (agent JAR + JRE). CI downloads latest agent SNAPSHOT from Gitea Maven registry. Uses `CAMELEER_SERVER_URL` env var (not CAMELEER_EXPORT_ENDPOINT).
|
||||
- `cameleer-server` / `cameleer-server-ui` — provisioned per-tenant (not in compose, created by `DockerTenantProvisioner`)
|
||||
- `cameleer-runtime-base` — base image for deployed apps (agent JAR + `cameleer-log-appender.jar` + JRE). CI downloads latest agent and log appender SNAPSHOTs from Gitea Maven registry. The Dockerfile ENTRYPOINT is overridden by `DockerRuntimeOrchestrator` at container creation; agent config uses `CAMELEER_AGENT_*` env vars set by `DeploymentExecutor`.
|
||||
- Docker builds: `--no-cache`, `--provenance=false` for Gitea compatibility
|
||||
- `docker-compose.dev.yml` — exposes ports for direct access, sets `SPRING_PROFILES_ACTIVE: dev`. Volume-mounts `./ui/dist` into the container so local UI builds are served without rebuilding the Docker image (`SPRING_WEB_RESOURCES_STATIC_LOCATIONS` overrides classpath). Adds Docker socket mount, jardata volume, and runtime env vars for container orchestration.
|
||||
- `docker-compose.yml` (root) — thin dev overlay (ports, volume mounts, `SPRING_PROFILES_ACTIVE: dev`). Chained on top of production templates from the installer submodule via `COMPOSE_FILE` in `.env`.
|
||||
- Installer is a **git submodule** at `installer/` pointing to `cameleer/cameleer-saas-installer` (public repo). Compose templates live there — single source of truth, no duplication. Run `git submodule update --remote installer` to pull template updates.
|
||||
- Design system: import from `@cameleer/design-system` (Gitea npm registry)
|
||||
|
||||
## Disabled Skills
|
||||
|
||||
- Do NOT use any `gsd:*` skills in this project. This includes all `/gsd:` prefixed commands.
|
||||
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **cameleer-saas** (3330 symbols, 7090 relationships, 281 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
|
||||
## When Debugging
|
||||
|
||||
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||
3. `READ gitnexus://repo/cameleer-saas/process/{processName}` — trace the full execution flow step by step
|
||||
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||
|
||||
## When Refactoring
|
||||
|
||||
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||
|
||||
## Tools Quick Reference
|
||||
|
||||
| Tool | When to use | Command |
|
||||
|------|-------------|---------|
|
||||
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||
|
||||
## Impact Risk Levels
|
||||
|
||||
| Depth | Meaning | Action |
|
||||
|-------|---------|--------|
|
||||
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/cameleer-saas/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/cameleer-saas/clusters` | All functional areas |
|
||||
| `gitnexus://repo/cameleer-saas/processes` | All execution flows |
|
||||
| `gitnexus://repo/cameleer-saas/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## Self-Check Before Finishing
|
||||
|
||||
Before completing any code modification task, verify:
|
||||
1. `gitnexus_impact` was run for all modified symbols
|
||||
2. No HIGH/CRITICAL risk warnings were ignored
|
||||
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||
4. All d=1 (WILL BREAK) dependents were updated
|
||||
|
||||
## Keeping the Index Fresh
|
||||
|
||||
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze
|
||||
```
|
||||
|
||||
If the index previously included embeddings, preserve them by adding `--embeddings`:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze --embeddings
|
||||
```
|
||||
|
||||
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
|
||||
|
||||
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
|
||||
|
||||
## CLI
|
||||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
||||
@@ -15,10 +15,10 @@ WORKDIR /build
|
||||
COPY .mvn/ .mvn/
|
||||
COPY mvnw pom.xml ./
|
||||
# Cache deps — BuildKit cache mount persists across --no-cache builds
|
||||
RUN --mount=type=cache,target=/root/.m2/repository ./mvnw dependency:go-offline -B || true
|
||||
RUN --mount=type=cache,target=/root/.m2/repository ./mvnw dependency:go-offline -U -B || true
|
||||
COPY src/ src/
|
||||
COPY --from=frontend /ui/dist/ src/main/resources/static/
|
||||
RUN --mount=type=cache,target=/root/.m2/repository ./mvnw package -DskipTests -B
|
||||
RUN --mount=type=cache,target=/root/.m2/repository ./mvnw package -DskipTests -U -B
|
||||
|
||||
# Runtime: target platform (amd64)
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
|
||||
92
HOWTO.md
@@ -35,19 +35,21 @@ curl http://localhost:8080/actuator/health
|
||||
|
||||
## Architecture
|
||||
|
||||
The platform runs as a Docker Compose stack with 6 services:
|
||||
The platform runs as a Docker Compose stack:
|
||||
|
||||
| Service | Image | Port | Purpose |
|
||||
|---------|-------|------|---------|
|
||||
| **traefik** | traefik:v3 | 80, 443 | Reverse proxy, TLS, routing |
|
||||
| **traefik-certs** | alpine:latest | — | Init container: generates self-signed cert or copies user-supplied cert |
|
||||
| **traefik** | traefik:v3 | 80, 443, 3002 | Reverse proxy, TLS termination, routing |
|
||||
| **postgres** | postgres:16-alpine | 5432* | Platform database + Logto database |
|
||||
| **logto** | ghcr.io/logto-io/logto | 3001*, 3002* | Identity provider (OIDC) |
|
||||
| **cameleer-saas** | cameleer-saas:latest | 8080* | SaaS API server |
|
||||
| **cameleer3-server** | cameleer3-server:latest | 8081 | Observability backend |
|
||||
| **cameleer-saas** | cameleer-saas:latest | 8080* | SaaS API server + vendor UI |
|
||||
| **clickhouse** | clickhouse-server:latest | 8123* | Trace/metrics/log storage |
|
||||
|
||||
*Ports exposed to host only with `docker-compose.dev.yml` overlay.
|
||||
|
||||
Per-tenant `cameleer-server` and `cameleer-server-ui` containers are provisioned dynamically by `DockerTenantProvisioner` — they are NOT part of the compose stack.
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Environment Configuration
|
||||
@@ -61,12 +63,10 @@ Edit `.env` and set at minimum:
|
||||
```bash
|
||||
# Change in production
|
||||
POSTGRES_PASSWORD=<strong-password>
|
||||
CAMELEER_AUTH_TOKEN=<random-string-for-agent-bootstrap>
|
||||
CAMELEER_TENANT_SLUG=<your-tenant-slug> # e.g., "acme" — tags all observability data
|
||||
|
||||
# Logto M2M credentials (get from Logto admin console after first boot)
|
||||
LOGTO_M2M_CLIENT_ID=
|
||||
LOGTO_M2M_CLIENT_SECRET=
|
||||
# Logto M2M credentials (auto-provisioned by bootstrap, or get from Logto admin console)
|
||||
CAMELEER_SAAS_IDENTITY_M2MCLIENTID=
|
||||
CAMELEER_SAAS_IDENTITY_M2MCLIENTSECRET=
|
||||
```
|
||||
|
||||
### 2. Ed25519 Keys
|
||||
@@ -83,7 +83,25 @@ This creates `keys/ed25519.key` (private) and `keys/ed25519.pub` (public). The k
|
||||
|
||||
If no key files are configured, the platform generates ephemeral keys on startup (suitable for development only -- keys change on every restart).
|
||||
|
||||
### 3. Start the Stack
|
||||
### 3. TLS Certificate (Optional)
|
||||
|
||||
By default, the `traefik-certs` init container generates a self-signed certificate for `PUBLIC_HOST`. To supply your own certificate at bootstrap time, set these env vars in `.env`:
|
||||
|
||||
```bash
|
||||
CERT_FILE=/path/to/cert.pem # PEM-encoded certificate
|
||||
KEY_FILE=/path/to/key.pem # PEM-encoded private key
|
||||
CA_FILE=/path/to/ca.pem # Optional: CA bundle (for private CA trust)
|
||||
```
|
||||
|
||||
The init container validates that the key matches the certificate before accepting. If validation fails, the container exits with an error.
|
||||
|
||||
**Runtime certificate replacement** is available via the vendor UI at `/vendor/certificates`:
|
||||
- Upload a new cert+key+CA bundle (staged, not yet active)
|
||||
- Validate and activate (atomic swap, Traefik hot-reloads)
|
||||
- Roll back to the previous certificate if needed
|
||||
- Track which tenants need a restart to pick up CA bundle changes
|
||||
|
||||
### 4. Start the Stack
|
||||
|
||||
**Development** (ports exposed for direct access):
|
||||
```bash
|
||||
@@ -95,7 +113,7 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 4. Verify Services
|
||||
### 5. Verify Services
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
@@ -119,8 +137,8 @@ On first boot, Logto seeds its database automatically. Access the admin console
|
||||
- Assign the **Logto Management API** resource with all scopes
|
||||
4. Update `.env`:
|
||||
```
|
||||
LOGTO_M2M_CLIENT_ID=<app-id>
|
||||
LOGTO_M2M_CLIENT_SECRET=<app-secret>
|
||||
CAMELEER_SAAS_IDENTITY_M2MCLIENTID=<app-id>
|
||||
CAMELEER_SAAS_IDENTITY_M2MCLIENTSECRET=<app-secret>
|
||||
```
|
||||
5. Restart cameleer-saas: `docker compose restart cameleer-saas`
|
||||
|
||||
@@ -204,7 +222,7 @@ To disable routing, set `exposedPort` to `null`.
|
||||
|
||||
### View the Observability Dashboard
|
||||
|
||||
The cameleer3-server React SPA dashboard is available at:
|
||||
The cameleer-server React SPA dashboard is available at:
|
||||
|
||||
```
|
||||
http://localhost/dashboard
|
||||
@@ -215,7 +233,7 @@ This shows execution traces, route topology graphs, metrics, and logs for all de
|
||||
### Check Agent & Observability Status
|
||||
|
||||
```bash
|
||||
# Is the agent registered with cameleer3-server?
|
||||
# Is the agent registered with cameleer-server?
|
||||
curl "http://localhost:8080/api/apps/$APP_ID/agent-status" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
# Returns: registered, state (ACTIVE/STALE/DEAD/UNKNOWN), routeIds
|
||||
@@ -285,7 +303,47 @@ Query params: `since`, `until` (ISO timestamps), `limit` (default 500), `stream`
|
||||
### Dashboard
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `/dashboard` | cameleer3-server observability dashboard (forward-auth protected) |
|
||||
| `/dashboard` | cameleer-server observability dashboard (forward-auth protected) |
|
||||
|
||||
### Vendor: Certificates (platform:admin)
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/vendor/certificates` | Overview (active, staged, archived, stale count) |
|
||||
| POST | `/api/vendor/certificates/stage` | Upload cert+key+CA (multipart) |
|
||||
| POST | `/api/vendor/certificates/activate` | Promote staged -> active |
|
||||
| POST | `/api/vendor/certificates/restore` | Swap archived <-> active |
|
||||
| DELETE | `/api/vendor/certificates/staged` | Discard staged cert |
|
||||
| GET | `/api/vendor/certificates/stale-tenants` | Count tenants needing CA restart |
|
||||
|
||||
### Vendor: Tenants (platform:admin)
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/vendor/tenants` | List all tenants (includes fleet health: agentCount, environmentCount, agentLimit) |
|
||||
| POST | `/api/vendor/tenants` | Create tenant (async provisioning) |
|
||||
| GET | `/api/vendor/tenants/{id}` | Tenant detail + server state |
|
||||
| POST | `/api/vendor/tenants/{id}/restart` | Restart server containers |
|
||||
| POST | `/api/vendor/tenants/{id}/suspend` | Suspend tenant |
|
||||
| POST | `/api/vendor/tenants/{id}/activate` | Activate tenant |
|
||||
| DELETE | `/api/vendor/tenants/{id}` | Delete tenant |
|
||||
| POST | `/api/vendor/tenants/{id}/license` | Renew license |
|
||||
|
||||
### Tenant Portal (org-scoped)
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/tenant/dashboard` | Tenant dashboard data |
|
||||
| GET | `/api/tenant/license` | License details |
|
||||
| POST | `/api/tenant/server/restart` | Restart server |
|
||||
| GET | `/api/tenant/team` | List team members |
|
||||
| POST | `/api/tenant/team/invite` | Invite team member |
|
||||
| DELETE | `/api/tenant/team/{userId}` | Remove team member |
|
||||
| GET | `/api/tenant/settings` | Tenant settings |
|
||||
| GET | `/api/tenant/sso` | List SSO connectors |
|
||||
| POST | `/api/tenant/sso` | Create SSO connector |
|
||||
| GET | `/api/tenant/ca` | List tenant CA certificates |
|
||||
| POST | `/api/tenant/ca` | Upload CA cert (staged) |
|
||||
| POST | `/api/tenant/ca/{id}/activate` | Activate staged CA cert |
|
||||
| DELETE | `/api/tenant/ca/{id}` | Remove CA cert |
|
||||
| GET | `/api/tenant/audit` | Tenant audit log |
|
||||
|
||||
### Health
|
||||
| Method | Path | Description |
|
||||
@@ -346,7 +404,7 @@ Output goes to `src/main/resources/static/` (configured in `vite.config.ts`). Th
|
||||
|
||||
### SPA Routing
|
||||
|
||||
Spring Boot serves `index.html` for all non-API routes via `SpaController.java`. React Router handles client-side routing. The SPA lives at `/`, while the observability dashboard (cameleer3-server) is at `/dashboard`.
|
||||
Spring Boot serves `index.html` for all non-API routes via `SpaController.java`. React Router handles client-side routing. The SPA lives at `/`, while the observability dashboard (cameleer-server) is at `/dashboard`.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
BIN
audit-screenshots/01-dashboard.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
audit-screenshots/02-user-menu-open.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
audit-screenshots/03-search-dialog.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
audit-screenshots/04-error-filter.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
audit-screenshots/05-license-page.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
audit-screenshots/06-license-token-shown.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
audit-screenshots/07-admin-tenants-error.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
audit-screenshots/08-dark-mode.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
audit-screenshots/09-sidebar-collapsed.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
audit-screenshots/10-server-dashboard.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
audit-screenshots/11-server-dashboard-tab.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
audit-screenshots/12-server-runtime.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
audit-screenshots/13-server-deployments.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
audit-screenshots/14-server-audit-log.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
audit-screenshots/15-server-environments.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
audit-screenshots/16-server-users-roles.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
audit-screenshots/17-server-oidc.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
audit-screenshots/17b-server-oidc-full.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
audit-screenshots/18-server-clickhouse.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
audit-screenshots/19-server-database.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
audit-screenshots/20-server-api-docs.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
audit-screenshots/21-auto-refresh-enabled.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
audit-screenshots/22-login-page.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
audit-screenshots/23-sign-in-page.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
audit-screenshots/24-dashboard-fullpage.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
audit/01-platform-dashboard.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
audit/02-user-menu-dropdown.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
audit/03-login-page.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
audit/04-login-error.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
audit/05-platform-dashboard-loggedin.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
audit/06-license-page.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
audit/07-license-token-revealed.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
audit/08-dashboard-dark-mode.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
audit/09-license-dark-mode.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
audit/10-server-dashboard.png
Normal file
|
After Width: | Height: | Size: 211 KiB |
BIN
audit/11-search-modal.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
audit/12-sidebar-collapsed.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
audit/13-responsive-tablet.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
audit/14-responsive-mobile.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
audit/15-dashboard-desktop-1280.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
audit/16-license-features-detail.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
audit/17-license-limits-detail.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
audit/18-license-validity-detail.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
audit/19-tenant-info-detail.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
audit/20-kpi-strip-detail.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
audit/21-sidebar-detail.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
audit/22-header-bar-detail.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
@@ -26,10 +26,10 @@
|
||||
| Severity | Issue | Element |
|
||||
|----------|-------|---------|
|
||||
| Important | **No password visibility toggle** -- the Password input uses `type="password"` with no eye icon to reveal. Most modern login forms offer this. | Password field |
|
||||
| Important | **Branding says "cameleer3"** not "Cameleer" or "Cameleer SaaS" -- the product name on the login page is the internal repo name, not the user-facing brand | `.logo` text content |
|
||||
| Important | **Branding says "cameleer"** not "Cameleer" or "Cameleer SaaS" -- the product name on the login page is the internal repo name, not the user-facing brand | `.logo` text content |
|
||||
| Nice-to-have | **No "Forgot password" link** -- even if it goes to a "contact admin" page, users expect this | Below password field |
|
||||
| Nice-to-have | **No Enter-key submit hint** -- though Enter does work via form submit, there's no visual affordance | Form area |
|
||||
| Nice-to-have | **Page title is "Sign in -- cameleer3"** -- should match product branding ("Cameleer SaaS") | `<title>` tag |
|
||||
| Nice-to-have | **Page title is "Sign in -- cameleer"** -- should match product branding ("Cameleer SaaS") | `<title>` tag |
|
||||
|
||||
---
|
||||
|
||||
@@ -216,7 +216,7 @@
|
||||
|
||||
### Important (17)
|
||||
1. No password visibility toggle on login
|
||||
2. Branding says "cameleer3" instead of product name on login
|
||||
2. Branding says "cameleer" instead of product name on login
|
||||
3. Breadcrumbs always empty on platform pages
|
||||
4. Massive empty space below dashboard content
|
||||
5. Tier badge color mapping inconsistent between Dashboard and License pages
|
||||
@@ -235,7 +235,7 @@
|
||||
|
||||
### Nice-to-have (8)
|
||||
1. No "Forgot password" link on login
|
||||
2. Login page title uses "cameleer3" branding
|
||||
2. Login page title uses "cameleer" branding
|
||||
3. No external link icon on "Open Server Dashboard"
|
||||
4. Avatar shows "AD" for "admin"
|
||||
5. No units on limit values
|
||||
|
||||
@@ -356,7 +356,7 @@ These use **different tier names** (enterprise/pro/starter vs BUSINESS/HIGH/MID/
|
||||
|
||||
3. **Hardcoded branding** (`SignInPage.tsx:61`):
|
||||
```tsx
|
||||
cameleer3
|
||||
cameleer
|
||||
```
|
||||
The brand name is hardcoded text, not sourced from configuration.
|
||||
|
||||
|
||||
BIN
audit/verify-01-dashboard.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
audit/verify-02-license.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
4974
ci-docker-log.txt
Normal file
@@ -1,48 +0,0 @@
|
||||
# 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"
|
||||
|
||||
cameleer-saas:
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./ui/dist:/app/static
|
||||
environment:
|
||||
SPRING_PROFILES_ACTIVE: dev
|
||||
SPRING_WEB_RESOURCES_STATIC_LOCATIONS: file:/app/static/,classpath:/static/
|
||||
|
||||
cameleer3-server:
|
||||
ports:
|
||||
- "8081:8081"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- jardata:/data/jars
|
||||
group_add:
|
||||
- "0"
|
||||
environment:
|
||||
CAMELEER_RUNTIME_ENABLED: "true"
|
||||
CAMELEER_JAR_STORAGE_PATH: /data/jars
|
||||
CAMELEER_RUNTIME_BASE_IMAGE: gitea.siegeln.net/cameleer/cameleer-runtime-base:latest
|
||||
CAMELEER_DOCKER_NETWORK: cameleer-saas_cameleer
|
||||
CAMELEER_SERVER_URL: http://cameleer3-server:8081
|
||||
CAMELEER_ROUTING_DOMAIN: ${PUBLIC_HOST:-localhost}
|
||||
CAMELEER_ROUTING_MODE: path
|
||||
CAMELEER_JAR_DOCKER_VOLUME: cameleer-saas_jardata
|
||||
|
||||
cameleer3-server-ui:
|
||||
ports:
|
||||
- "8082:80"
|
||||
|
||||
clickhouse:
|
||||
ports:
|
||||
- "8123:8123"
|
||||
|
||||
volumes:
|
||||
jardata:
|
||||
@@ -1,246 +1,23 @@
|
||||
# Dev overrides — layered on top of installer/templates/ via COMPOSE_FILE in .env
|
||||
# Usage: docker compose up (reads .env automatically)
|
||||
services:
|
||||
traefik-certs:
|
||||
image: alpine:latest
|
||||
restart: "no"
|
||||
entrypoint: ["sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
if [ ! -f /certs/cert.pem ]; then
|
||||
apk add --no-cache openssl >/dev/null 2>&1
|
||||
openssl req -x509 -newkey rsa:4096 \
|
||||
-keyout /certs/key.pem -out /certs/cert.pem \
|
||||
-days 365 -nodes \
|
||||
-subj "/CN=$$PUBLIC_HOST" \
|
||||
-addext "subjectAltName=DNS:$$PUBLIC_HOST,DNS:*.$$PUBLIC_HOST"
|
||||
echo "Generated self-signed cert for $$PUBLIC_HOST"
|
||||
else
|
||||
echo "Certs already exist, skipping"
|
||||
fi
|
||||
environment:
|
||||
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
|
||||
volumes:
|
||||
- certs:/certs
|
||||
|
||||
traefik:
|
||||
image: traefik:v3
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
traefik-certs:
|
||||
condition: service_completed_successfully
|
||||
cameleer-postgres:
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "3002:3002"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./traefik.yml:/etc/traefik/traefik.yml:ro
|
||||
- ./docker/traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
|
||||
- certs:/etc/traefik/certs:ro
|
||||
networks:
|
||||
- cameleer
|
||||
- cameleer-traefik
|
||||
- "5432:5432"
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
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} -d ${POSTGRES_DB:-cameleer_saas}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- cameleer
|
||||
cameleer-clickhouse:
|
||||
ports:
|
||||
- "8123:8123"
|
||||
|
||||
logto:
|
||||
image: ${LOGTO_IMAGE:-gitea.siegeln.net/cameleer/cameleer-logto}:${VERSION:-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: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
||||
ADMIN_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:3002
|
||||
TRUST_PROXY_HEADER: 1
|
||||
NODE_TLS_REJECT_UNAUTHORIZED: "0" # dev only — accept self-signed cert for internal OIDC discovery
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\""]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 15s
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.logto.rule=PathPrefix(`/`)
|
||||
- traefik.http.routers.logto.priority=1
|
||||
- traefik.http.routers.logto.entrypoints=websecure
|
||||
- traefik.http.routers.logto.tls=true
|
||||
- traefik.http.routers.logto.service=logto
|
||||
- traefik.http.routers.logto.middlewares=logto-cors
|
||||
- traefik.http.middlewares.logto-cors.headers.accessControlAllowOriginList=${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:3002
|
||||
- traefik.http.middlewares.logto-cors.headers.accessControlAllowMethods=GET,POST,PUT,PATCH,DELETE,OPTIONS
|
||||
- traefik.http.middlewares.logto-cors.headers.accessControlAllowHeaders=Authorization,Content-Type
|
||||
- traefik.http.middlewares.logto-cors.headers.accessControlAllowCredentials=true
|
||||
- traefik.http.services.logto.loadbalancer.server.port=3001
|
||||
- traefik.http.routers.logto-console.rule=PathPrefix(`/`)
|
||||
- traefik.http.routers.logto-console.entrypoints=admin-console
|
||||
- traefik.http.routers.logto-console.tls=true
|
||||
- traefik.http.routers.logto-console.service=logto-console
|
||||
- traefik.http.services.logto-console.loadbalancer.server.port=3002
|
||||
networks:
|
||||
- cameleer
|
||||
|
||||
logto-bootstrap:
|
||||
image: postgres:16-alpine
|
||||
depends_on:
|
||||
logto:
|
||||
condition: service_healthy
|
||||
cameleer3-server:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
entrypoint: ["sh", "/scripts/logto-bootstrap.sh"]
|
||||
environment:
|
||||
LOGTO_ENDPOINT: http://logto:3001
|
||||
LOGTO_ADMIN_ENDPOINT: http://logto:3002
|
||||
LOGTO_PUBLIC_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
||||
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
|
||||
PUBLIC_PROTOCOL: ${PUBLIC_PROTOCOL:-https}
|
||||
PG_HOST: postgres
|
||||
PG_USER: ${POSTGRES_USER:-cameleer}
|
||||
PG_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
|
||||
PG_DB_SAAS: ${POSTGRES_DB:-cameleer_saas}
|
||||
SAAS_ADMIN_USER: ${SAAS_ADMIN_USER:-admin}
|
||||
SAAS_ADMIN_PASS: ${SAAS_ADMIN_PASS:-admin}
|
||||
TENANT_ADMIN_USER: ${TENANT_ADMIN_USER:-camel}
|
||||
TENANT_ADMIN_PASS: ${TENANT_ADMIN_PASS:-camel}
|
||||
CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}
|
||||
SERVER_ENDPOINT: http://cameleer3-server:8081
|
||||
SERVER_UI_USER: ${CAMELEER_UI_USER:-admin}
|
||||
SERVER_UI_PASS: ${CAMELEER_UI_PASSWORD:-admin}
|
||||
volumes:
|
||||
- ./docker/logto-bootstrap.sh:/scripts/logto-bootstrap.sh:ro
|
||||
- bootstrapdata:/data
|
||||
networks:
|
||||
- cameleer
|
||||
cameleer-logto:
|
||||
ports:
|
||||
- "3001:3001"
|
||||
|
||||
cameleer-saas:
|
||||
image: ${CAMELEER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-saas}:${VERSION:-latest}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
logto-bootstrap:
|
||||
condition: service_completed_successfully
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- bootstrapdata:/data/bootstrap:ro
|
||||
- ./ui/dist:/app/static
|
||||
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_PUBLIC_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
||||
LOGTO_ISSUER_URI: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}/oidc
|
||||
LOGTO_JWK_SET_URI: ${LOGTO_ENDPOINT:-http://logto:3001}/oidc/jwks
|
||||
LOGTO_M2M_CLIENT_ID: ${LOGTO_M2M_CLIENT_ID:-}
|
||||
LOGTO_M2M_CLIENT_SECRET: ${LOGTO_M2M_CLIENT_SECRET:-}
|
||||
CAMELEER3_SERVER_ENDPOINT: http://cameleer3-server:8081
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.saas.rule=PathPrefix(`/platform`)
|
||||
- traefik.http.routers.saas.entrypoints=websecure
|
||||
- traefik.http.routers.saas.tls=true
|
||||
- traefik.http.services.saas.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/cameleer3
|
||||
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
|
||||
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
|
||||
CLICKHOUSE_URL: jdbc:clickhouse://clickhouse:8123/cameleer
|
||||
CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}
|
||||
CAMELEER_JWT_SECRET: ${CAMELEER_JWT_SECRET:-cameleer-dev-jwt-secret-change-in-production}
|
||||
CAMELEER_TENANT_ID: ${CAMELEER_TENANT_SLUG:-default}
|
||||
CAMELEER_OIDC_ISSUER_URI: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}/oidc
|
||||
CAMELEER_OIDC_JWK_SET_URI: ${LOGTO_ENDPOINT:-http://logto:3001}/oidc/jwks
|
||||
CAMELEER_OIDC_TLS_SKIP_VERIFY: "true" # dev only — disable in production with real certs
|
||||
CAMELEER_OIDC_AUDIENCE: ${CAMELEER_OIDC_AUDIENCE:-https://api.cameleer.local}
|
||||
CAMELEER_CORS_ALLOWED_ORIGINS: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:8081/api/v1/health || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 15s
|
||||
labels:
|
||||
- traefik.enable=false
|
||||
networks:
|
||||
cameleer:
|
||||
cameleer-traefik:
|
||||
aliases:
|
||||
- cameleer3-server
|
||||
|
||||
cameleer3-server-ui:
|
||||
image: ${CAMELEER3_SERVER_UI_IMAGE:-gitea.siegeln.net/cameleer/cameleer3-server-ui}:${VERSION:-latest}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
cameleer3-server:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
CAMELEER_API_URL: http://cameleer3-server:8081
|
||||
BASE_PATH: /server
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.server-ui.rule=PathPrefix(`/server`)
|
||||
- traefik.http.routers.server-ui.entrypoints=websecure
|
||||
- traefik.http.routers.server-ui.tls=true
|
||||
- traefik.http.routers.server-ui.middlewares=server-ui-strip
|
||||
- traefik.http.middlewares.server-ui-strip.stripprefix.prefixes=/server
|
||||
- traefik.http.routers.server-ui.service=server-ui
|
||||
- traefik.http.services.server-ui.loadbalancer.server.port=80
|
||||
networks:
|
||||
- cameleer
|
||||
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- chdata:/var/lib/clickhouse
|
||||
- ./docker/clickhouse-init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
- ./docker/clickhouse-users.xml:/etc/clickhouse-server/users.d/default-user.xml:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "clickhouse-client --query 'SELECT 1'"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
networks:
|
||||
- cameleer
|
||||
|
||||
networks:
|
||||
cameleer:
|
||||
driver: bridge
|
||||
cameleer-traefik:
|
||||
name: cameleer-traefik
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
chdata:
|
||||
certs:
|
||||
bootstrapdata:
|
||||
SPRING_PROFILES_ACTIVE: dev
|
||||
SPRING_WEB_RESOURCES_STATIC_LOCATIONS: file:/app/static/,classpath:/static/
|
||||
|
||||
93
docker/CLAUDE.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Docker & Infrastructure
|
||||
|
||||
## Routing (single-domain, path-based via Traefik)
|
||||
|
||||
All services on one hostname. Infrastructure containers (Traefik, Logto) use `PUBLIC_HOST` + `PUBLIC_PROTOCOL` env vars directly. The SaaS app reads these via `CAMELEER_SAAS_PROVISIONING_PUBLICHOST` / `CAMELEER_SAAS_PROVISIONING_PUBLICPROTOCOL` (Spring Boot properties `cameleer.saas.provisioning.publichost` / `cameleer.saas.provisioning.publicprotocol`).
|
||||
|
||||
| Path | Target | Notes |
|
||||
|------|--------|-------|
|
||||
| `/platform/*` | cameleer-saas:8080 | SPA + API (`server.servlet.context-path: /platform`) |
|
||||
| `/platform/vendor/*` | (SPA routes) | Vendor console (platform:admin) |
|
||||
| `/platform/tenant/*` | (SPA routes) | Tenant admin portal (org-scoped) |
|
||||
| `/t/{slug}/*` | per-tenant server-ui | Provisioned tenant UI containers (Traefik labels) |
|
||||
| `/` | redirect -> `/platform/` | Via `docker/traefik-dynamic.yml` |
|
||||
| `/*` (catch-all) | cameleer-logto:3001 (priority=1) | Custom sign-in UI, OIDC, interaction |
|
||||
|
||||
- SPA assets at `/_app/` (Vite `assetsDir: '_app'`) to avoid conflict with Logto's `/assets/`
|
||||
- Logto `ENDPOINT` = `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}` (same domain, same origin)
|
||||
- TLS: `traefik-certs` init container generates self-signed cert (dev) or copies user-supplied cert via `CERT_FILE`/`KEY_FILE`/`CA_FILE` env vars. Default cert configured in `docker/traefik-dynamic.yml` (NOT static `traefik.yml` — Traefik v3 ignores `tls.stores.default` in static config). Runtime cert replacement via vendor UI (stage/activate/restore). ACME for production (future). Server containers import `/certs/ca.pem` into JVM truststore at startup via `docker-entrypoint.sh` for OIDC trust.
|
||||
- Root `/` -> `/platform/` redirect via Traefik file provider (`docker/traefik-dynamic.yml`)
|
||||
- LoginPage auto-redirects to Logto OIDC (no intermediate button)
|
||||
- Per-tenant server containers get Traefik labels for `/t/{slug}/*` routing at provisioning time
|
||||
|
||||
## Docker Networks
|
||||
|
||||
Compose-defined networks:
|
||||
|
||||
| Network | Name on Host | Purpose |
|
||||
|---------|-------------|---------|
|
||||
| `cameleer` | `cameleer-saas_cameleer` | Compose default — shared services (DB, Logto, SaaS) |
|
||||
| `cameleer-traefik` | `cameleer-traefik` (fixed `name:`) | Traefik + provisioned tenant containers |
|
||||
|
||||
Per-tenant networks (created dynamically by `DockerTenantProvisioner`):
|
||||
|
||||
| Network | Name Pattern | Purpose |
|
||||
|---------|-------------|---------|
|
||||
| Tenant network | `cameleer-tenant-{slug}` | Internal bridge, no internet — isolates tenant server + apps |
|
||||
| Environment network | `cameleer-env-{tenantId}-{envSlug}` | Tenant-scoped (includes tenantId to prevent slug collision across tenants) |
|
||||
|
||||
Server containers join three networks: tenant network (primary), shared services network (`cameleer`), and traefik network. Apps deployed by the server use the tenant network as primary.
|
||||
|
||||
**Backend IP resolution:** Traefik's Docker provider is configured with `network: cameleer-traefik` (static `traefik.yml`). Every cameleer-managed container — saas-provisioned tenant containers (via `DockerTenantProvisioner`) and cameleer-server's per-app containers (via `DockerNetworkManager`) — is attached to `cameleer-traefik` at creation, so Traefik always resolves a reachable backend IP. Provisioned tenant containers additionally emit a `traefik.docker.network=cameleer-traefik` label as per-service defense-in-depth. (Pre-2026-04-23 the static config pointed at `network: cameleer`, a name that never matched any real network — that produced 504 Gateway Timeout on every managed app until the Traefik image was rebuilt.)
|
||||
|
||||
## Custom sign-in UI (`ui/sign-in/`)
|
||||
|
||||
Separate Vite+React SPA replacing Logto's default sign-in page. Supports both sign-in and self-service registration (registration is disabled by default until the vendor admin configures an email connector via the UI).
|
||||
|
||||
- Built as custom Logto Docker image (`cameleer-logto`): `ui/sign-in/Dockerfile` = node build stage + `FROM ghcr.io/logto-io/logto:latest` + install official connectors + COPY dist over `/etc/logto/packages/experience/dist/`
|
||||
- Uses `@cameleer/design-system` components (Card, Input, Button, FormField, Alert)
|
||||
- **Sign-in**: Logto Experience API (4-step: init -> verify password -> identify -> submit -> redirect). Auto-detects email vs username identifier.
|
||||
- **Registration**: 2-phase flow. Phase 1: init Register -> send verification code to email. Phase 2: verify code -> set password -> identify (creates user) -> submit -> redirect.
|
||||
- Reads `first_screen=register` from URL query params to show register form initially (set by `@logto/react` SDK's `firstScreen` option)
|
||||
- `CUSTOM_UI_PATH` env var does NOT work for Logto OSS — must volume-mount or replace the experience dist directory
|
||||
- Favicon bundled in `ui/sign-in/public/favicon.svg` (served by Logto, not SaaS)
|
||||
|
||||
## Deployment pipeline
|
||||
|
||||
App deployment is handled by the cameleer-server's `DeploymentExecutor` (7-stage async flow):
|
||||
1. PRE_FLIGHT — validate config, check JAR exists
|
||||
2. PULL_IMAGE — pull base image if missing
|
||||
3. CREATE_NETWORK — ensure cameleer-traefik and cameleer-env-{slug} networks
|
||||
4. START_REPLICAS — create N containers with Traefik labels
|
||||
5. HEALTH_CHECK — poll `/cameleer/health` on agent port 9464
|
||||
6. SWAP_TRAFFIC — stop old deployment (blue/green)
|
||||
7. COMPLETE — mark RUNNING or DEGRADED
|
||||
|
||||
Key files:
|
||||
- `DeploymentExecutor.java` (in cameleer-server) — async staged deployment, runtime type auto-detection
|
||||
- `DockerRuntimeOrchestrator.java` (in cameleer-server) — Docker client, container lifecycle, builds runtime-type-specific entrypoints (spring-boot uses `-cp` + `PropertiesLauncher` with `-Dloader.path` for log appender; quarkus uses `-jar`; plain-java uses `-cp` + detected main class; native exec directly). Overrides the Dockerfile ENTRYPOINT.
|
||||
- `docker/runtime-base/Dockerfile` — base image with agent JAR + `cameleer-log-appender.jar` + JRE. The Dockerfile ENTRYPOINT (`-jar /app/app.jar`) is a fallback — `DockerRuntimeOrchestrator` overrides it at container creation.
|
||||
- `RuntimeDetector.java` (in cameleer-server) — detects runtime type from JAR manifest `Main-Class`; derives correct `PropertiesLauncher` package (Spring Boot 3.2+ vs pre-3.2)
|
||||
- `ServerApiClient.java` — M2M token acquisition for SaaS->server API calls (agent status). Uses `X-Cameleer-Protocol-Version: 1` header
|
||||
- Docker socket access: `group_add: ["0"]` in docker-compose.dev.yml (not root group membership in Dockerfile)
|
||||
- Network: deployed containers join `cameleer-tenant-{slug}` (primary, isolation) + `cameleer-traefik` (routing) + `cameleer-env-{tenantId}-{envSlug}` (environment isolation)
|
||||
|
||||
## Bootstrap (`docker/logto-bootstrap.sh`)
|
||||
|
||||
Idempotent script run inside the Logto container entrypoint. **Clean slate** — no example tenant, no viewer user, no server configuration. Phases:
|
||||
1. Wait for Logto health (no server to wait for — servers are provisioned per-tenant)
|
||||
2. Get Management API token (reads `m-default` secret from DB)
|
||||
3. Create Logto apps (SPA, Traditional Web App with `skipConsent`, M2M with Management API role + server API role)
|
||||
3b. Create API resource scopes (1 platform + 9 tenant + 3 server scopes)
|
||||
4. Create org roles (owner, operator, viewer with API resource scope assignments) + M2M server role (`cameleer-m2m-server` with `server:admin` scope)
|
||||
5. Create admin user (SaaS admin with Logto console access). `SAAS_ADMIN_USER` is the admin's email address in SaaS mode — used as both the Logto username and primaryEmail. No separate `SAAS_ADMIN_EMAIL`.
|
||||
7b. Configure Logto Custom JWT for access tokens (maps org roles -> `roles` claim: owner->server:admin, operator->server:operator, viewer->server:viewer; saas-vendor global role -> server:admin)
|
||||
8. Configure Logto sign-in branding (Cameleer colors `#C6820E`/`#D4941E`, logo from `/platform/logo.svg`)
|
||||
8c. Configure sign-in experience (sign-in only) — sets `signInMode: "SignIn"` with username+password method. Registration is disabled by default; the vendor admin enables it via the Email Connector UI after configuring SMTP delivery.
|
||||
9. Cleanup seeded Logto apps
|
||||
10. Write bootstrap results to `/data/logto-bootstrap.json`
|
||||
12. Create `saas-vendor` global role with all API scopes and assign to admin user (always runs — admin IS the platform admin).
|
||||
|
||||
SMTP / email connector configuration is managed at runtime via the vendor admin UI (Email Connector page). The bootstrap no longer creates email connectors — it defaults to sign-in only mode. Registration is enabled automatically when the admin configures an email connector through the UI.
|
||||
|
||||
The multi-tenant compose stack is: Traefik + PostgreSQL + ClickHouse + Logto (with bootstrap entrypoint) + cameleer-saas. No `cameleer-server` or `cameleer-server-ui` in compose — those are provisioned per-tenant by `DockerTenantProvisioner`.
|
||||
4
docker/cameleer-clickhouse/Dockerfile
Normal file
@@ -0,0 +1,4 @@
|
||||
FROM clickhouse/clickhouse-server:latest
|
||||
COPY init.sql /docker-entrypoint-initdb.d/init.sql
|
||||
COPY users.xml /etc/clickhouse-server/users.d/default-user.xml
|
||||
COPY prometheus.xml /etc/clickhouse-server/config.d/prometheus.xml
|
||||
9
docker/cameleer-clickhouse/prometheus.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<clickhouse>
|
||||
<prometheus>
|
||||
<endpoint>/metrics</endpoint>
|
||||
<port>9363</port>
|
||||
<metrics>true</metrics>
|
||||
<events>true</events>
|
||||
<asynchronous_metrics>true</asynchronous_metrics>
|
||||
</prometheus>
|
||||
</clickhouse>
|
||||
16
docker/cameleer-clickhouse/users.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<clickhouse>
|
||||
<users>
|
||||
<default remove="remove">
|
||||
</default>
|
||||
|
||||
<default>
|
||||
<profile>default</profile>
|
||||
<networks>
|
||||
<ip>::/0</ip>
|
||||
</networks>
|
||||
<password from_env="CLICKHOUSE_PASSWORD" />
|
||||
<quota>default</quota>
|
||||
<access_management>0</access_management>
|
||||
</default>
|
||||
</users>
|
||||
</clickhouse>
|
||||
73
docker/cameleer-logto/logto-entrypoint.sh
Normal file
@@ -0,0 +1,73 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Build DB_URL from individual env vars so passwords with special characters
|
||||
# are properly URL-encoded (Logto only accepts a connection string)
|
||||
if [ -z "$DB_URL" ]; then
|
||||
ENCODED_PW=$(node -e "process.stdout.write(encodeURIComponent(process.env.PG_PASSWORD || ''))")
|
||||
export DB_URL="postgres://${PG_USER:-cameleer}:${ENCODED_PW}@${PG_HOST:-localhost}:5432/logto"
|
||||
echo "[entrypoint] Built DB_URL from PG_USER/PG_PASSWORD/PG_HOST"
|
||||
fi
|
||||
|
||||
# Save the real public endpoints for after bootstrap
|
||||
REAL_ENDPOINT="$ENDPOINT"
|
||||
REAL_ADMIN_ENDPOINT="$ADMIN_ENDPOINT"
|
||||
|
||||
echo "[entrypoint] Seeding Logto database..."
|
||||
npm run cli db seed -- --swe 2>/dev/null || true
|
||||
|
||||
echo "[entrypoint] Deploying database alterations..."
|
||||
npm run cli db alteration deploy 2>/dev/null || true
|
||||
|
||||
# Start Logto with localhost endpoints so it can reach itself without Traefik
|
||||
export ENDPOINT="http://localhost:3001"
|
||||
export ADMIN_ENDPOINT="http://localhost:3002"
|
||||
|
||||
echo "[entrypoint] Starting Logto (bootstrap mode)..."
|
||||
npm start &
|
||||
LOGTO_PID=$!
|
||||
|
||||
echo "[entrypoint] Waiting for Logto to be ready..."
|
||||
for i in $(seq 1 120); do
|
||||
if node -e "require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))" 2>/dev/null; then
|
||||
echo "[entrypoint] Logto is ready."
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 120 ]; then
|
||||
echo "[entrypoint] ERROR: Logto not ready after 120s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Run bootstrap — use localhost endpoints, skip Host headers (BOOTSTRAP_LOCAL flag)
|
||||
# PUBLIC_HOST and PUBLIC_PROTOCOL stay real for redirect URI generation
|
||||
BOOTSTRAP_FILE="/data/logto-bootstrap.json"
|
||||
export LOGTO_ENDPOINT="http://localhost:3001"
|
||||
export LOGTO_ADMIN_ENDPOINT="http://localhost:3002"
|
||||
export BOOTSTRAP_LOCAL="true"
|
||||
|
||||
if [ -f "$BOOTSTRAP_FILE" ]; then
|
||||
CACHED_SECRET=$(jq -r '.m2mClientSecret // empty' "$BOOTSTRAP_FILE" 2>/dev/null)
|
||||
CACHED_SPA=$(jq -r '.spaClientId // empty' "$BOOTSTRAP_FILE" 2>/dev/null)
|
||||
if [ -n "$CACHED_SECRET" ] && [ -n "$CACHED_SPA" ]; then
|
||||
echo "[entrypoint] Bootstrap already complete."
|
||||
else
|
||||
echo "[entrypoint] Incomplete bootstrap found, re-running..."
|
||||
/scripts/logto-bootstrap.sh
|
||||
fi
|
||||
else
|
||||
echo "[entrypoint] Running bootstrap..."
|
||||
/scripts/logto-bootstrap.sh
|
||||
fi
|
||||
|
||||
# Restart Logto with real public endpoints
|
||||
echo "[entrypoint] Bootstrap done. Restarting Logto with public endpoints..."
|
||||
kill $LOGTO_PID 2>/dev/null || true
|
||||
wait $LOGTO_PID 2>/dev/null || true
|
||||
|
||||
export ENDPOINT="$REAL_ENDPOINT"
|
||||
export ADMIN_ENDPOINT="$REAL_ADMIN_ENDPOINT"
|
||||
|
||||
echo "[entrypoint] Starting Logto (production mode)..."
|
||||
exec npm start
|
||||
3
docker/cameleer-postgres/Dockerfile
Normal file
@@ -0,0 +1,3 @@
|
||||
FROM postgres:16-alpine
|
||||
COPY init-databases.sh /docker-entrypoint-initdb.d/init-databases.sh
|
||||
RUN chmod +x /docker-entrypoint-initdb.d/init-databases.sh
|
||||
@@ -3,7 +3,7 @@ set -e
|
||||
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
||||
CREATE DATABASE logto;
|
||||
CREATE DATABASE cameleer3;
|
||||
CREATE DATABASE cameleer;
|
||||
GRANT ALL PRIVILEGES ON DATABASE logto TO $POSTGRES_USER;
|
||||
GRANT ALL PRIVILEGES ON DATABASE cameleer3 TO $POSTGRES_USER;
|
||||
GRANT ALL PRIVILEGES ON DATABASE cameleer TO $POSTGRES_USER;
|
||||
EOSQL
|
||||
7
docker/cameleer-traefik/Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
||||
FROM traefik:v3
|
||||
RUN apk add --no-cache openssl
|
||||
COPY traefik.yml /etc/traefik/traefik.yml
|
||||
COPY traefik-dynamic.yml /etc/traefik/dynamic.yml
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
68
docker/cameleer-traefik/entrypoint.sh
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
CERTS_DIR="/certs"
|
||||
|
||||
# Skip if certs already exist (idempotent)
|
||||
if [ ! -f "$CERTS_DIR/cert.pem" ]; then
|
||||
mkdir -p "$CERTS_DIR"
|
||||
|
||||
if [ -n "$CERT_FILE" ] && [ -n "$KEY_FILE" ]; then
|
||||
# User-supplied certificate
|
||||
echo "[certs] Installing user-supplied certificate..."
|
||||
cp "$CERT_FILE" "$CERTS_DIR/cert.pem"
|
||||
cp "$KEY_FILE" "$CERTS_DIR/key.pem"
|
||||
if [ -n "$CA_FILE" ]; then
|
||||
cp "$CA_FILE" "$CERTS_DIR/ca.pem"
|
||||
fi
|
||||
# Validate key matches cert
|
||||
CERT_MOD=$(openssl x509 -noout -modulus -in "$CERTS_DIR/cert.pem" 2>/dev/null | md5sum)
|
||||
KEY_MOD=$(openssl rsa -noout -modulus -in "$CERTS_DIR/key.pem" 2>/dev/null | md5sum)
|
||||
if [ "$CERT_MOD" != "$KEY_MOD" ]; then
|
||||
echo "[certs] ERROR: Certificate and key do not match!"
|
||||
rm -f "$CERTS_DIR/cert.pem" "$CERTS_DIR/key.pem" "$CERTS_DIR/ca.pem"
|
||||
exit 1
|
||||
fi
|
||||
SELF_SIGNED=false
|
||||
echo "[certs] Installed user-supplied certificate."
|
||||
else
|
||||
# Generate self-signed certificate
|
||||
HOST="${PUBLIC_HOST:-localhost}"
|
||||
AUTH="${AUTH_HOST:-$HOST}"
|
||||
echo "[certs] Generating self-signed certificate for $HOST..."
|
||||
# Build SAN list; deduplicate when AUTH_HOST equals PUBLIC_HOST
|
||||
if [ "$AUTH" = "$HOST" ]; then
|
||||
SAN="DNS:$HOST,DNS:*.$HOST"
|
||||
else
|
||||
SAN="DNS:$HOST,DNS:*.$HOST,DNS:$AUTH,DNS:*.$AUTH"
|
||||
echo "[certs] (+ auth domain: $AUTH)"
|
||||
fi
|
||||
openssl req -x509 -newkey rsa:4096 \
|
||||
-keyout "$CERTS_DIR/key.pem" -out "$CERTS_DIR/cert.pem" \
|
||||
-days 365 -nodes \
|
||||
-subj "/CN=$HOST" \
|
||||
-addext "subjectAltName=$SAN"
|
||||
SELF_SIGNED=true
|
||||
echo "[certs] Generated self-signed certificate for $HOST."
|
||||
fi
|
||||
|
||||
# Write metadata for SaaS app to seed DB
|
||||
SUBJECT=$(openssl x509 -noout -subject -in "$CERTS_DIR/cert.pem" 2>/dev/null | sed 's/subject=//')
|
||||
FINGERPRINT=$(openssl x509 -noout -fingerprint -sha256 -in "$CERTS_DIR/cert.pem" 2>/dev/null | sed 's/.*=//')
|
||||
NOT_BEFORE=$(openssl x509 -noout -startdate -in "$CERTS_DIR/cert.pem" 2>/dev/null | sed 's/notBefore=//')
|
||||
NOT_AFTER=$(openssl x509 -noout -enddate -in "$CERTS_DIR/cert.pem" 2>/dev/null | sed 's/notAfter=//')
|
||||
HAS_CA=false
|
||||
[ -f "$CERTS_DIR/ca.pem" ] && HAS_CA=true
|
||||
cat > "$CERTS_DIR/meta.json" <<METAEOF
|
||||
{"subject":"$SUBJECT","fingerprint":"$FINGERPRINT","selfSigned":$SELF_SIGNED,"hasCa":$HAS_CA,"notBefore":"$NOT_BEFORE","notAfter":"$NOT_AFTER"}
|
||||
METAEOF
|
||||
|
||||
mkdir -p "$CERTS_DIR/staged" "$CERTS_DIR/prev"
|
||||
chmod 775 "$CERTS_DIR" "$CERTS_DIR/staged" "$CERTS_DIR/prev"
|
||||
chmod 660 "$CERTS_DIR"/*.pem 2>/dev/null || true
|
||||
else
|
||||
echo "[certs] Certificates already exist, skipping generation."
|
||||
fi
|
||||
|
||||
# Start Traefik
|
||||
exec traefik "$@"
|
||||
6
docker/cameleer-traefik/traefik-dynamic.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
tls:
|
||||
stores:
|
||||
default:
|
||||
defaultCertificate:
|
||||
certFile: /certs/cert.pem
|
||||
keyFile: /certs/key.pem
|
||||
@@ -18,13 +18,6 @@ providers:
|
||||
docker:
|
||||
endpoint: "unix:///var/run/docker.sock"
|
||||
exposedByDefault: false
|
||||
network: cameleer
|
||||
network: cameleer-traefik
|
||||
file:
|
||||
filename: /etc/traefik/dynamic.yml
|
||||
|
||||
tls:
|
||||
stores:
|
||||
default:
|
||||
defaultCertificate:
|
||||
certFile: /etc/traefik/certs/cert.pem
|
||||
keyFile: /etc/traefik/certs/key.pem
|
||||
@@ -1,9 +0,0 @@
|
||||
<clickhouse>
|
||||
<users>
|
||||
<default>
|
||||
<networks>
|
||||
<ip>::/0</ip>
|
||||
</networks>
|
||||
</default>
|
||||
</users>
|
||||
</clickhouse>
|
||||
@@ -4,15 +4,15 @@ set -e
|
||||
# Cameleer SaaS — Bootstrap Script
|
||||
# Creates Logto apps, users, organizations, roles.
|
||||
# Seeds cameleer_saas DB with tenant, environment, license.
|
||||
# Configures cameleer3-server OIDC.
|
||||
# Configures cameleer-server OIDC.
|
||||
# Idempotent: checks existence before creating.
|
||||
|
||||
LOGTO_ENDPOINT="${LOGTO_ENDPOINT:-http://logto:3001}"
|
||||
LOGTO_ADMIN_ENDPOINT="${LOGTO_ADMIN_ENDPOINT:-http://logto:3002}"
|
||||
LOGTO_ENDPOINT="${LOGTO_ENDPOINT:-http://cameleer-logto:3001}"
|
||||
LOGTO_ADMIN_ENDPOINT="${LOGTO_ADMIN_ENDPOINT:-http://cameleer-logto:3002}"
|
||||
LOGTO_PUBLIC_ENDPOINT="${LOGTO_PUBLIC_ENDPOINT:-http://localhost:3001}"
|
||||
MGMT_API_RESOURCE="https://default.logto.app/api"
|
||||
BOOTSTRAP_FILE="/data/logto-bootstrap.json"
|
||||
PG_HOST="${PG_HOST:-postgres}"
|
||||
PG_HOST="${PG_HOST:-cameleer-postgres}"
|
||||
PG_USER="${PG_USER:-cameleer}"
|
||||
PG_DB_LOGTO="logto"
|
||||
PG_DB_SAAS="${PG_DB_SAAS:-cameleer_saas}"
|
||||
@@ -25,34 +25,52 @@ API_RESOURCE_INDICATOR="https://api.cameleer.local"
|
||||
API_RESOURCE_NAME="Cameleer SaaS API"
|
||||
|
||||
# Users (configurable via env vars)
|
||||
# In SaaS mode, SAAS_ADMIN_USER is the admin's email address (e.g. admin@company.com).
|
||||
# The local part (before @) is used as the Logto username; the full value as primaryEmail.
|
||||
SAAS_ADMIN_USER="${SAAS_ADMIN_USER:-admin}"
|
||||
SAAS_ADMIN_PASS="${SAAS_ADMIN_PASS:-admin}"
|
||||
TENANT_ADMIN_USER="${TENANT_ADMIN_USER:-camel}"
|
||||
TENANT_ADMIN_PASS="${TENANT_ADMIN_PASS:-camel}"
|
||||
# Extract username (local part) for Logto — Logto rejects @ in usernames
|
||||
if echo "$SAAS_ADMIN_USER" | grep -q '@'; then
|
||||
ADMIN_USERNAME="${SAAS_ADMIN_USER%%@*}"
|
||||
ADMIN_EMAIL="$SAAS_ADMIN_USER"
|
||||
else
|
||||
ADMIN_USERNAME="$SAAS_ADMIN_USER"
|
||||
ADMIN_EMAIL=""
|
||||
fi
|
||||
|
||||
# Tenant config
|
||||
TENANT_NAME="Example Tenant"
|
||||
TENANT_SLUG="default"
|
||||
BOOTSTRAP_TOKEN="${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}"
|
||||
|
||||
# Server config
|
||||
SERVER_ENDPOINT="${SERVER_ENDPOINT:-http://cameleer3-server:8081}"
|
||||
SERVER_UI_USER="${SERVER_UI_USER:-admin}"
|
||||
SERVER_UI_PASS="${SERVER_UI_PASS:-admin}"
|
||||
# No server config — servers are provisioned dynamically by the admin console
|
||||
|
||||
# Redirect URIs (derived from PUBLIC_HOST and PUBLIC_PROTOCOL)
|
||||
HOST="${PUBLIC_HOST:-localhost}"
|
||||
AUTH="${AUTH_HOST:-$HOST}"
|
||||
PROTO="${PUBLIC_PROTOCOL:-https}"
|
||||
SPA_REDIRECT_URIS="[\"${PROTO}://${HOST}/platform/callback\"]"
|
||||
SPA_POST_LOGOUT_URIS="[\"${PROTO}://${HOST}/platform/login\"]"
|
||||
SPA_POST_LOGOUT_URIS="[\"${PROTO}://${HOST}/platform/login\",\"${PROTO}://${HOST}/platform/\"]"
|
||||
TRAD_REDIRECT_URIS="[\"${PROTO}://${HOST}/oidc/callback\",\"${PROTO}://${HOST}/server/oidc/callback\"]"
|
||||
TRAD_POST_LOGOUT_URIS="[\"${PROTO}://${HOST}\",\"${PROTO}://${HOST}/server\",\"${PROTO}://${HOST}/server/login?local\"]"
|
||||
|
||||
log() { echo "[bootstrap] $1"; }
|
||||
pgpass() { PGPASSWORD="${PG_PASSWORD:-cameleer_dev}"; export PGPASSWORD; }
|
||||
|
||||
# Install jq + curl
|
||||
apk add --no-cache jq curl >/dev/null 2>&1
|
||||
# When BOOTSTRAP_LOCAL=true (running inside Logto container with localhost endpoints),
|
||||
# skip Host/X-Forwarded-Proto headers — they cause issuer mismatches with localhost
|
||||
if [ "$BOOTSTRAP_LOCAL" = "true" ]; then
|
||||
HOST_ARGS=""
|
||||
ADMIN_HOST_ARGS=""
|
||||
else
|
||||
# Logto validates Host header against its ENDPOINT, which uses AUTH_HOST
|
||||
HOST_ARGS="-H Host:${AUTH}"
|
||||
ADMIN_HOST_ARGS="-H Host:${AUTH}:3002 -H X-Forwarded-Proto:https"
|
||||
fi
|
||||
|
||||
# Install jq + curl if not already available (deps are baked into cameleer-logto image)
|
||||
if ! command -v jq >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1; then
|
||||
if command -v apk >/dev/null 2>&1; then
|
||||
apk add --no-cache jq curl >/dev/null 2>&1
|
||||
elif command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update -qq && apt-get install -y -qq jq curl >/dev/null 2>&1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Read cached secrets from previous run
|
||||
if [ -f "$BOOTSTRAP_FILE" ]; then
|
||||
@@ -80,15 +98,7 @@ for i in $(seq 1 60); do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
log "Waiting for cameleer3-server..."
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf "${SERVER_ENDPOINT}/api/v1/health" >/dev/null 2>&1; then
|
||||
log "cameleer3-server is ready."
|
||||
break
|
||||
fi
|
||||
[ "$i" -eq 60 ] && { log "WARNING: cameleer3-server not ready after 60s — skipping OIDC config"; }
|
||||
sleep 1
|
||||
done
|
||||
# No server wait — servers are provisioned dynamically by the admin console
|
||||
|
||||
# ============================================================
|
||||
# PHASE 2: Get Management API token
|
||||
@@ -103,15 +113,14 @@ M_DEFAULT_SECRET=$(psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB_LOGTO" -t -A -c \
|
||||
get_admin_token() {
|
||||
curl -s -X POST "${LOGTO_ADMIN_ENDPOINT}/oidc/token" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-H "Host: ${HOST}:3002" \
|
||||
-H "X-Forwarded-Proto: https" \
|
||||
$ADMIN_HOST_ARGS \
|
||||
-d "grant_type=client_credentials&client_id=${1}&client_secret=${2}&resource=${MGMT_API_RESOURCE}&scope=all"
|
||||
}
|
||||
|
||||
get_default_token() {
|
||||
curl -s -X POST "${LOGTO_ENDPOINT}/oidc/token" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-H "Host: ${HOST}" \
|
||||
$HOST_ARGS \
|
||||
-d "grant_type=client_credentials&client_id=${1}&client_secret=${2}&resource=${MGMT_API_RESOURCE}&scope=all"
|
||||
}
|
||||
|
||||
@@ -124,7 +133,7 @@ log "Got Management API token."
|
||||
# Verify Management API is fully ready (Logto may still be initializing internally)
|
||||
log "Verifying Management API is responsive..."
|
||||
for i in $(seq 1 30); do
|
||||
VERIFY_RESPONSE=$(curl -s -H "Authorization: Bearer $TOKEN" -H "Host: ${HOST}" "${LOGTO_ENDPOINT}/api/roles" 2>/dev/null)
|
||||
VERIFY_RESPONSE=$(curl -s -H "Authorization: Bearer $TOKEN" $HOST_ARGS "${LOGTO_ENDPOINT}/api/roles" 2>/dev/null)
|
||||
if echo "$VERIFY_RESPONSE" | jq -e 'type == "array"' >/dev/null 2>&1; then
|
||||
log "Management API is ready."
|
||||
break
|
||||
@@ -135,21 +144,21 @@ done
|
||||
|
||||
# --- Helper: Logto API calls ---
|
||||
api_get() {
|
||||
curl -s -H "Authorization: Bearer $TOKEN" -H "Host: ${HOST}" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || echo "[]"
|
||||
curl -s -H "Authorization: Bearer $TOKEN" $HOST_ARGS "${LOGTO_ENDPOINT}${1}" 2>/dev/null || echo "[]"
|
||||
}
|
||||
api_post() {
|
||||
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -H "Host: ${HOST}" \
|
||||
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" $HOST_ARGS \
|
||||
-d "$2" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
|
||||
}
|
||||
api_put() {
|
||||
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -H "Host: ${HOST}" \
|
||||
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" $HOST_ARGS \
|
||||
-d "$2" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
|
||||
}
|
||||
api_delete() {
|
||||
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" -H "Host: ${HOST}" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
|
||||
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" $HOST_ARGS "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
|
||||
}
|
||||
api_patch() {
|
||||
curl -s -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -H "Host: ${HOST}" \
|
||||
curl -s -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" $HOST_ARGS \
|
||||
-d "$2" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
|
||||
}
|
||||
|
||||
@@ -177,7 +186,7 @@ else
|
||||
log "Created SPA app: $SPA_ID"
|
||||
fi
|
||||
|
||||
# --- Traditional Web App (for cameleer3-server OIDC) ---
|
||||
# --- Traditional Web App (for cameleer-server OIDC) ---
|
||||
TRAD_ID=$(echo "$EXISTING_APPS" | jq -r ".[] | select(.name == \"$TRAD_APP_NAME\" and .type == \"Traditional\") | .id")
|
||||
TRAD_SECRET=""
|
||||
if [ -n "$TRAD_ID" ]; then
|
||||
@@ -342,8 +351,7 @@ fi
|
||||
# ============================================================
|
||||
|
||||
# --- Organization roles: owner, operator, viewer ---
|
||||
# Note: platform-admin / saas-vendor global role is NOT created here.
|
||||
# It is injected via docker/vendor-seed.sh on the hosted SaaS environment only.
|
||||
# Note: saas-vendor global role is created in Phase 12 and assigned to the admin user.
|
||||
log "Creating organization roles..."
|
||||
EXISTING_ORG_ROLES=$(api_get "/api/organization-roles")
|
||||
|
||||
@@ -391,21 +399,27 @@ log "API resource scopes assigned to organization roles."
|
||||
# ============================================================
|
||||
|
||||
# --- Platform Owner ---
|
||||
log "Checking for platform owner user '$SAAS_ADMIN_USER'..."
|
||||
ADMIN_USER_ID=$(api_get "/api/users?search=$SAAS_ADMIN_USER" | jq -r ".[] | select(.username == \"$SAAS_ADMIN_USER\") | .id")
|
||||
log "Checking for platform owner user '$ADMIN_USERNAME'..."
|
||||
ADMIN_USER_ID=$(api_get "/api/users?search=$ADMIN_USERNAME" | jq -r ".[] | select(.username == \"$ADMIN_USERNAME\") | .id")
|
||||
if [ -n "$ADMIN_USER_ID" ]; then
|
||||
log "Platform owner exists: $ADMIN_USER_ID"
|
||||
else
|
||||
log "Creating platform owner '$SAAS_ADMIN_USER'..."
|
||||
ADMIN_RESPONSE=$(api_post "/api/users" "{
|
||||
\"username\": \"$SAAS_ADMIN_USER\",
|
||||
\"password\": \"$SAAS_ADMIN_PASS\",
|
||||
\"name\": \"Platform Owner\"
|
||||
}")
|
||||
# Build user JSON — include primaryEmail only if SAAS_ADMIN_USER is an email
|
||||
ADMIN_USER_JSON="{\"username\": \"$ADMIN_USERNAME\", \"password\": \"$SAAS_ADMIN_PASS\", \"name\": \"Platform Owner\""
|
||||
if [ -n "$ADMIN_EMAIL" ]; then
|
||||
ADMIN_USER_JSON="$ADMIN_USER_JSON, \"primaryEmail\": \"$ADMIN_EMAIL\""
|
||||
log "Creating platform owner '$ADMIN_USERNAME' (email: $ADMIN_EMAIL)..."
|
||||
else
|
||||
log "Creating platform owner '$ADMIN_USERNAME'..."
|
||||
fi
|
||||
ADMIN_USER_JSON="$ADMIN_USER_JSON}"
|
||||
ADMIN_RESPONSE=$(api_post "/api/users" "$ADMIN_USER_JSON")
|
||||
ADMIN_USER_ID=$(echo "$ADMIN_RESPONSE" | jq -r '.id')
|
||||
log "Created platform owner: $ADMIN_USER_ID"
|
||||
# No global role assigned — owner role is org-scoped.
|
||||
# SaaS vendor role is injected via docker/vendor-seed.sh on hosted environments.
|
||||
if [ -z "$ADMIN_USER_ID" ] || [ "$ADMIN_USER_ID" = "null" ]; then
|
||||
log "ERROR: Failed to create platform owner. Response: $(echo "$ADMIN_RESPONSE" | head -c 300)"
|
||||
else
|
||||
log "Created platform owner: $ADMIN_USER_ID"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Grant SaaS admin Logto console access (admin tenant, port 3002) ---
|
||||
@@ -422,8 +436,7 @@ if [ -z "$M_ADMIN_SECRET" ]; then
|
||||
else
|
||||
ADMIN_TOKEN_RESPONSE=$(curl -s -X POST "${LOGTO_ADMIN_ENDPOINT}/oidc/token" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-H "Host: ${HOST}:3002" \
|
||||
-H "X-Forwarded-Proto: https" \
|
||||
$ADMIN_HOST_ARGS \
|
||||
-d "grant_type=client_credentials&client_id=m-admin&client_secret=${M_ADMIN_SECRET}&resource=${ADMIN_MGMT_RESOURCE}&scope=all")
|
||||
ADMIN_TOKEN=$(echo "$ADMIN_TOKEN_RESPONSE" | jq -r '.access_token' 2>/dev/null)
|
||||
|
||||
@@ -435,23 +448,23 @@ else
|
||||
|
||||
# Admin-tenant API helpers (port 3002, admin token)
|
||||
admin_api_get() {
|
||||
curl -s -H "Authorization: Bearer $ADMIN_TOKEN" -H "Host: ${HOST}:3002" -H "X-Forwarded-Proto: https" "${LOGTO_ADMIN_ENDPOINT}${1}" 2>/dev/null || echo "[]"
|
||||
curl -s -H "Authorization: Bearer $ADMIN_TOKEN" $ADMIN_HOST_ARGS "${LOGTO_ADMIN_ENDPOINT}${1}" 2>/dev/null || echo "[]"
|
||||
}
|
||||
admin_api_post() {
|
||||
curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" -H "Host: ${HOST}:3002" -H "X-Forwarded-Proto: https" \
|
||||
curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" $ADMIN_HOST_ARGS \
|
||||
-d "$2" "${LOGTO_ADMIN_ENDPOINT}${1}" 2>/dev/null || true
|
||||
}
|
||||
admin_api_patch() {
|
||||
curl -s -X PATCH -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" -H "Host: ${HOST}:3002" -H "X-Forwarded-Proto: https" \
|
||||
curl -s -X PATCH -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" $ADMIN_HOST_ARGS \
|
||||
-d "$2" "${LOGTO_ADMIN_ENDPOINT}${1}" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Check if admin user already exists on admin tenant
|
||||
ADMIN_TENANT_USER_ID=$(admin_api_get "/api/users?search=$SAAS_ADMIN_USER" | jq -r ".[] | select(.username == \"$SAAS_ADMIN_USER\") | .id" 2>/dev/null)
|
||||
# Check if admin user already exists on admin tenant (uses ADMIN_USERNAME, not email)
|
||||
ADMIN_TENANT_USER_ID=$(admin_api_get "/api/users?search=$ADMIN_USERNAME" | jq -r ".[] | select(.username == \"$ADMIN_USERNAME\") | .id" 2>/dev/null)
|
||||
if [ -z "$ADMIN_TENANT_USER_ID" ] || [ "$ADMIN_TENANT_USER_ID" = "null" ]; then
|
||||
log "Creating admin console user '$SAAS_ADMIN_USER'..."
|
||||
log "Creating admin console user '$ADMIN_USERNAME'..."
|
||||
ADMIN_TENANT_RESPONSE=$(admin_api_post "/api/users" "{
|
||||
\"username\": \"$SAAS_ADMIN_USER\",
|
||||
\"username\": \"$ADMIN_USERNAME\",
|
||||
\"password\": \"$SAAS_ADMIN_PASS\",
|
||||
\"name\": \"Platform Admin\"
|
||||
}")
|
||||
@@ -479,16 +492,31 @@ if [ -n "$ADMIN_TENANT_USER_ID" ] && [ "$ADMIN_TENANT_USER_ID" != "null" ]; then
|
||||
log "WARNING: admin tenant roles not found"
|
||||
fi
|
||||
|
||||
# Add to t-default organization with admin role
|
||||
admin_api_post "/api/organizations/t-default/users" "{\"userIds\": [\"$ADMIN_TENANT_USER_ID\"]}" >/dev/null 2>&1
|
||||
TENANT_ADMIN_ORG_ROLE_ID=$(admin_api_get "/api/organization-roles" | jq -r '.[] | select(.name == "admin") | .id')
|
||||
if [ -n "$TENANT_ADMIN_ORG_ROLE_ID" ] && [ "$TENANT_ADMIN_ORG_ROLE_ID" != "null" ]; then
|
||||
admin_api_post "/api/organizations/t-default/users/$ADMIN_TENANT_USER_ID/roles" "{\"organizationRoleIds\": [\"$TENANT_ADMIN_ORG_ROLE_ID\"]}" >/dev/null 2>&1
|
||||
log "Added to t-default organization with admin role."
|
||||
fi
|
||||
# Switch admin tenant sign-in mode from Register to SignIn (user already created)
|
||||
# Switch sign-in mode from Register to SignIn (admin user already created)
|
||||
admin_api_patch "/api/sign-in-exp" '{"signInMode": "SignIn"}' >/dev/null 2>&1
|
||||
log "Set admin tenant sign-in mode to SignIn."
|
||||
log "Set sign-in mode to SignIn."
|
||||
|
||||
# Register admin-console redirect URIs (Logto ships with empty URIs)
|
||||
ADMIN_PUBLIC="${ADMIN_ENDPOINT:-${PROTO}://${HOST}:3002}"
|
||||
admin_api_patch "/api/applications/admin-console" "{
|
||||
\"oidcClientMetadata\": {
|
||||
\"redirectUris\": [\"${ADMIN_PUBLIC}/console/callback\"],
|
||||
\"postLogoutRedirectUris\": [\"${ADMIN_PUBLIC}/console\"]
|
||||
}
|
||||
}" >/dev/null 2>&1
|
||||
log "Registered admin-console redirect URIs."
|
||||
|
||||
# Add admin user to Logto's internal organizations (required for console login)
|
||||
for ORG_ID in t-default t-admin; do
|
||||
admin_api_post "/api/organizations/${ORG_ID}/users" "{\"userIds\": [\"$ADMIN_TENANT_USER_ID\"]}" >/dev/null 2>&1
|
||||
done
|
||||
ADMIN_ORG_ROLE_ID=$(admin_api_get "/api/organization-roles" | jq -r '.[] | select(.name == "admin") | .id')
|
||||
if [ -n "$ADMIN_ORG_ROLE_ID" ] && [ "$ADMIN_ORG_ROLE_ID" != "null" ]; then
|
||||
for ORG_ID in t-default t-admin; do
|
||||
admin_api_post "/api/organizations/${ORG_ID}/users/${ADMIN_TENANT_USER_ID}/roles" "{\"organizationRoleIds\": [\"$ADMIN_ORG_ROLE_ID\"]}" >/dev/null 2>&1
|
||||
done
|
||||
fi
|
||||
log "Added admin to Logto console organizations."
|
||||
|
||||
log "SaaS admin granted Logto console access."
|
||||
else
|
||||
@@ -498,130 +526,10 @@ fi
|
||||
fi # end: ADMIN_TOKEN check
|
||||
fi # end: M_ADMIN_SECRET check
|
||||
|
||||
# --- Viewer user (for testing read-only OIDC role in server) ---
|
||||
log "Checking for viewer user '$TENANT_ADMIN_USER'..."
|
||||
TENANT_USER_ID=$(api_get "/api/users?search=$TENANT_ADMIN_USER" | jq -r ".[] | select(.username == \"$TENANT_ADMIN_USER\") | .id")
|
||||
if [ -n "$TENANT_USER_ID" ]; then
|
||||
log "Viewer user exists: $TENANT_USER_ID"
|
||||
else
|
||||
log "Creating viewer user '$TENANT_ADMIN_USER'..."
|
||||
TENANT_RESPONSE=$(api_post "/api/users" "{
|
||||
\"username\": \"$TENANT_ADMIN_USER\",
|
||||
\"password\": \"$TENANT_ADMIN_PASS\",
|
||||
\"name\": \"Viewer\"
|
||||
}")
|
||||
TENANT_USER_ID=$(echo "$TENANT_RESPONSE" | jq -r '.id')
|
||||
log "Created viewer user: $TENANT_USER_ID"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# PHASE 6: Create organization + add users
|
||||
# ============================================================
|
||||
|
||||
log "Checking for organization '$TENANT_NAME'..."
|
||||
EXISTING_ORGS=$(api_get "/api/organizations")
|
||||
ORG_ID=$(echo "$EXISTING_ORGS" | jq -r ".[] | select(.name == \"$TENANT_NAME\") | .id")
|
||||
|
||||
if [ -n "$ORG_ID" ]; then
|
||||
log "Organization exists: $ORG_ID"
|
||||
else
|
||||
log "Creating organization '$TENANT_NAME'..."
|
||||
ORG_RESPONSE=$(api_post "/api/organizations" "{
|
||||
\"name\": \"$TENANT_NAME\",
|
||||
\"description\": \"Bootstrap demo tenant\"
|
||||
}")
|
||||
ORG_ID=$(echo "$ORG_RESPONSE" | jq -r '.id')
|
||||
log "Created organization: $ORG_ID"
|
||||
fi
|
||||
|
||||
# Add users to organization
|
||||
if [ -n "$ADMIN_USER_ID" ] && [ "$ADMIN_USER_ID" != "null" ]; then
|
||||
log "Adding platform owner to organization..."
|
||||
api_post "/api/organizations/$ORG_ID/users" "{\"userIds\": [\"$ADMIN_USER_ID\"]}" >/dev/null 2>&1
|
||||
api_put "/api/organizations/$ORG_ID/users/$ADMIN_USER_ID/roles" "{\"organizationRoleIds\": [\"$ORG_OWNER_ROLE_ID\"]}" >/dev/null 2>&1
|
||||
log "Platform owner added to org with owner role."
|
||||
fi
|
||||
|
||||
if [ -n "$TENANT_USER_ID" ] && [ "$TENANT_USER_ID" != "null" ]; then
|
||||
log "Adding viewer user to organization..."
|
||||
api_post "/api/organizations/$ORG_ID/users" "{\"userIds\": [\"$TENANT_USER_ID\"]}" >/dev/null 2>&1
|
||||
api_put "/api/organizations/$ORG_ID/users/$TENANT_USER_ID/roles" "{\"organizationRoleIds\": [\"$ORG_VIEWER_ROLE_ID\"]}" >/dev/null 2>&1
|
||||
log "Viewer user added to org with viewer role."
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# PHASE 7: Configure cameleer3-server OIDC
|
||||
# ============================================================
|
||||
|
||||
SERVER_HEALTHY="no"
|
||||
for i in 1 2 3; do
|
||||
if curl -sf "${SERVER_ENDPOINT}/api/v1/health" >/dev/null 2>&1; then
|
||||
SERVER_HEALTHY="yes"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
log "Phase 7 check: SERVER_HEALTHY=$SERVER_HEALTHY, TRAD_SECRET length=${#TRAD_SECRET}"
|
||||
|
||||
if [ "$SERVER_HEALTHY" = "yes" ] && [ -n "$TRAD_SECRET" ]; then
|
||||
log "Configuring cameleer3-server OIDC..."
|
||||
|
||||
# Login to server as admin
|
||||
SERVER_TOKEN_RESPONSE=$(curl -s -X POST "${SERVER_ENDPOINT}/api/v1/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"username\": \"$SERVER_UI_USER\", \"password\": \"$SERVER_UI_PASS\"}")
|
||||
SERVER_TOKEN=$(echo "$SERVER_TOKEN_RESPONSE" | jq -r '.accessToken' 2>/dev/null)
|
||||
|
||||
if [ -n "$SERVER_TOKEN" ] && [ "$SERVER_TOKEN" != "null" ]; then
|
||||
# Configure OIDC
|
||||
OIDC_RESPONSE=$(curl -s -X PUT "${SERVER_ENDPOINT}/api/v1/admin/oidc" \
|
||||
-H "Authorization: Bearer $SERVER_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"enabled\": true,
|
||||
\"issuerUri\": \"$LOGTO_PUBLIC_ENDPOINT/oidc\",
|
||||
\"clientId\": \"$TRAD_ID\",
|
||||
\"clientSecret\": \"$TRAD_SECRET\",
|
||||
\"autoSignup\": true,
|
||||
\"defaultRoles\": [\"VIEWER\"],
|
||||
\"displayNameClaim\": \"name\",
|
||||
\"rolesClaim\": \"roles\",
|
||||
\"audience\": \"$API_RESOURCE_INDICATOR\",
|
||||
\"additionalScopes\": []
|
||||
}")
|
||||
log "OIDC config response: $(echo "$OIDC_RESPONSE" | head -c 200)"
|
||||
log "cameleer3-server OIDC configured."
|
||||
|
||||
# Seed claim mapping rules (roles → server RBAC)
|
||||
log "Seeding claim mapping rules..."
|
||||
EXISTING_MAPPINGS=$(curl -s -H "Authorization: Bearer $SERVER_TOKEN" \
|
||||
"${SERVER_ENDPOINT}/api/v1/admin/claim-mappings" 2>/dev/null || echo "[]")
|
||||
|
||||
seed_claim_mapping() {
|
||||
local match_value="$1"
|
||||
local target="$2"
|
||||
local priority="$3"
|
||||
local exists=$(echo "$EXISTING_MAPPINGS" | jq -r ".[] | select(.matchValue == \"$match_value\") | .id")
|
||||
if [ -n "$exists" ]; then
|
||||
log " Claim mapping '$match_value' → $target exists"
|
||||
else
|
||||
local resp=$(curl -s -X POST "${SERVER_ENDPOINT}/api/v1/admin/claim-mappings" \
|
||||
-H "Authorization: Bearer $SERVER_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"claim\":\"roles\",\"matchType\":\"contains\",\"matchValue\":\"$match_value\",\"action\":\"assignRole\",\"target\":\"$target\",\"priority\":$priority}")
|
||||
log " Created claim mapping '$match_value' → $target"
|
||||
fi
|
||||
}
|
||||
|
||||
seed_claim_mapping "server:admin" "ADMIN" 10
|
||||
seed_claim_mapping "server:operator" "OPERATOR" 20
|
||||
log "Claim mapping rules seeded."
|
||||
else
|
||||
log "WARNING: Could not login to cameleer3-server — skipping OIDC config"
|
||||
fi
|
||||
else
|
||||
log "WARNING: cameleer3-server not available or no Traditional app secret — skipping OIDC config"
|
||||
fi
|
||||
# No viewer user — tenant users are created by the admin during tenant provisioning.
|
||||
# No example organization — tenants are created via the admin console.
|
||||
# No server OIDC config — each provisioned server gets OIDC from env vars.
|
||||
ORG_ID=""
|
||||
|
||||
# ============================================================
|
||||
# PHASE 7b: Configure Logto Custom JWT for access tokens
|
||||
@@ -644,7 +552,15 @@ CUSTOM_JWT_SCRIPT='const getCustomJwtClaims = async ({ token, context, environme
|
||||
if (role.name === "saas-vendor") roles.add("server:admin");
|
||||
}
|
||||
}
|
||||
return roles.size > 0 ? { roles: [...roles] } : {};
|
||||
const mfaFactors = context?.user?.mfaVerificationFactors || [];
|
||||
const mfaEnrolled = mfaFactors.some(f => f.type === "Totp" || f.type === "WebAuthn");
|
||||
const passkeyEnrolled = mfaFactors.some(f => f.type === "WebAuthn");
|
||||
const claims = {};
|
||||
if (roles.size > 0) claims.roles = [...roles];
|
||||
claims.mfa_enrolled = mfaEnrolled;
|
||||
claims.passkey_enrolled = passkeyEnrolled;
|
||||
claims.mfa_method_preference = context?.user?.customData?.mfa_method_preference || null;
|
||||
return claims;
|
||||
};'
|
||||
|
||||
CUSTOM_JWT_PAYLOAD=$(jq -n --arg script "$CUSTOM_JWT_SCRIPT" '{ script: $script }')
|
||||
@@ -674,6 +590,38 @@ api_patch "/api/sign-in-exp" "{
|
||||
}"
|
||||
log "Sign-in branding configured."
|
||||
|
||||
# ============================================================
|
||||
# PHASE 8c: Configure sign-in experience (sign-in only)
|
||||
# ============================================================
|
||||
# Registration is disabled by default. The vendor admin enables it
|
||||
# via the Email Connector UI after configuring SMTP delivery.
|
||||
|
||||
log "Configuring sign-in experience (sign-in only, no registration)..."
|
||||
api_patch "/api/sign-in-exp" '{
|
||||
"signInMode": "SignIn",
|
||||
"signIn": {
|
||||
"methods": [
|
||||
{
|
||||
"identifier": "email",
|
||||
"password": true,
|
||||
"verificationCode": false,
|
||||
"isPasswordPrimary": true
|
||||
},
|
||||
{
|
||||
"identifier": "username",
|
||||
"password": true,
|
||||
"verificationCode": false,
|
||||
"isPasswordPrimary": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"mfa": {
|
||||
"factors": ["Totp", "BackupCode"],
|
||||
"policy": "UserControlled"
|
||||
}
|
||||
}' >/dev/null 2>&1
|
||||
log "Sign-in experience configured: SignIn only (registration disabled until email is configured)."
|
||||
|
||||
# ============================================================
|
||||
# PHASE 9: Cleanup seeded apps
|
||||
# ============================================================
|
||||
@@ -702,25 +650,52 @@ cat > "$BOOTSTRAP_FILE" <<EOF
|
||||
"tradAppId": "$TRAD_ID",
|
||||
"tradAppSecret": "$TRAD_SECRET",
|
||||
"apiResourceIndicator": "$API_RESOURCE_INDICATOR",
|
||||
"organizationId": "$ORG_ID",
|
||||
"tenantName": "$TENANT_NAME",
|
||||
"tenantSlug": "$TENANT_SLUG",
|
||||
"bootstrapToken": "$BOOTSTRAP_TOKEN",
|
||||
"platformAdminUser": "$SAAS_ADMIN_USER",
|
||||
"tenantAdminUser": "$TENANT_ADMIN_USER",
|
||||
"oidcIssuerUri": "${LOGTO_ENDPOINT}/oidc",
|
||||
"oidcAudience": "$API_RESOURCE_INDICATOR"
|
||||
}
|
||||
EOF
|
||||
chmod 644 "$BOOTSTRAP_FILE"
|
||||
|
||||
# ============================================================
|
||||
# Phase 12: SaaS Admin Role
|
||||
# ============================================================
|
||||
|
||||
log ""
|
||||
log "=== Phase 12: SaaS Admin Role ==="
|
||||
|
||||
# Create saas-vendor global role with all API scopes
|
||||
log "Checking for saas-vendor role..."
|
||||
EXISTING_ROLES=$(api_get "/api/roles")
|
||||
VENDOR_ROLE_ID=$(echo "$EXISTING_ROLES" | jq -r '.[] | select(.name == "saas-vendor" and .type == "User") | .id')
|
||||
|
||||
if [ -z "$VENDOR_ROLE_ID" ]; then
|
||||
ALL_SCOPE_IDS=$(api_get "/api/resources/$API_RESOURCE_ID/scopes" | jq '[.[].id]')
|
||||
log "Creating saas-vendor role with all scopes..."
|
||||
VENDOR_ROLE_RESPONSE=$(api_post "/api/roles" "{
|
||||
\"name\": \"saas-vendor\",
|
||||
\"description\": \"SaaS vendor — full platform control across all tenants\",
|
||||
\"type\": \"User\",
|
||||
\"scopeIds\": $ALL_SCOPE_IDS
|
||||
}")
|
||||
VENDOR_ROLE_ID=$(echo "$VENDOR_ROLE_RESPONSE" | jq -r '.id')
|
||||
log "Created saas-vendor role: $VENDOR_ROLE_ID"
|
||||
else
|
||||
log "saas-vendor role exists: $VENDOR_ROLE_ID"
|
||||
fi
|
||||
|
||||
# Assign vendor role to admin user
|
||||
if [ -n "$VENDOR_ROLE_ID" ] && [ "$VENDOR_ROLE_ID" != "null" ] && [ -n "$ADMIN_USER_ID" ]; then
|
||||
api_post "/api/users/$ADMIN_USER_ID/roles" "{\"roleIds\": [\"$VENDOR_ROLE_ID\"]}" >/dev/null 2>&1
|
||||
log "Assigned saas-vendor role to admin user."
|
||||
fi
|
||||
|
||||
log "SaaS admin role configured."
|
||||
|
||||
log ""
|
||||
log "=== Bootstrap complete! ==="
|
||||
# dev only — remove credential logging in production
|
||||
log " Platform Owner: $SAAS_ADMIN_USER / $SAAS_ADMIN_PASS (org role: owner)"
|
||||
log " Viewer: $TENANT_ADMIN_USER / $TENANT_ADMIN_PASS (org role: viewer)"
|
||||
log " Tenant: $TENANT_NAME (slug: $TENANT_SLUG)"
|
||||
log " Organization: $ORG_ID"
|
||||
log " SPA Client ID: $SPA_ID"
|
||||
log ""
|
||||
log " To add SaaS Vendor role (hosted only): run docker/vendor-seed.sh"
|
||||
log " No tenants created — use the admin console to create tenants."
|
||||
log ""
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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
|
||||
# Agent JAR and log appender JAR are copied during CI build from Gitea Maven registry
|
||||
COPY agent.jar /app/agent.jar
|
||||
COPY cameleer-log-appender.jar /app/cameleer-log-appender.jar
|
||||
|
||||
ENTRYPOINT exec java \
|
||||
-Dcameleer.export.type=${CAMELEER_EXPORT_TYPE:-HTTP} \
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
http:
|
||||
routers:
|
||||
root-redirect:
|
||||
rule: "Path(`/`)"
|
||||
priority: 100
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls: {}
|
||||
middlewares:
|
||||
- root-to-platform
|
||||
service: saas@docker
|
||||
middlewares:
|
||||
root-to-platform:
|
||||
redirectRegex:
|
||||
regex: "^(https?://[^/]+)/?$"
|
||||
replacement: "${1}/platform/"
|
||||
permanent: false
|
||||
@@ -1,135 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Cameleer SaaS — Vendor Seed Script
|
||||
# Creates the saas-vendor global role and vendor user.
|
||||
# Run ONCE on the hosted SaaS environment AFTER standard bootstrap.
|
||||
# NOT part of docker-compose.yml — invoked manually or by CI.
|
||||
|
||||
LOGTO_ENDPOINT="${LOGTO_ENDPOINT:-http://logto:3001}"
|
||||
MGMT_API_RESOURCE="https://default.logto.app/api"
|
||||
API_RESOURCE_INDICATOR="https://api.cameleer.local"
|
||||
PG_HOST="${PG_HOST:-postgres}"
|
||||
PG_USER="${PG_USER:-cameleer}"
|
||||
PG_DB_LOGTO="logto"
|
||||
|
||||
# Vendor credentials (override via env vars)
|
||||
VENDOR_USER="${VENDOR_USER:-vendor}"
|
||||
VENDOR_PASS="${VENDOR_PASS:-vendor}"
|
||||
VENDOR_NAME="${VENDOR_NAME:-SaaS Vendor}"
|
||||
|
||||
log() { echo "[vendor-seed] $1"; }
|
||||
pgpass() { PGPASSWORD="${PG_PASSWORD:-cameleer_dev}"; export PGPASSWORD; }
|
||||
|
||||
# Install jq + curl
|
||||
apk add --no-cache jq curl >/dev/null 2>&1
|
||||
|
||||
# ============================================================
|
||||
# Get Management API token
|
||||
# ============================================================
|
||||
|
||||
log "Reading M2M credentials from bootstrap file..."
|
||||
BOOTSTRAP_FILE="/data/logto-bootstrap.json"
|
||||
if [ ! -f "$BOOTSTRAP_FILE" ]; then
|
||||
log "ERROR: Bootstrap file not found at $BOOTSTRAP_FILE — run standard bootstrap first"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
M2M_ID=$(jq -r '.m2mClientId' "$BOOTSTRAP_FILE")
|
||||
M2M_SECRET=$(jq -r '.m2mClientSecret' "$BOOTSTRAP_FILE")
|
||||
|
||||
if [ -z "$M2M_ID" ] || [ "$M2M_ID" = "null" ] || [ -z "$M2M_SECRET" ] || [ "$M2M_SECRET" = "null" ]; then
|
||||
log "ERROR: M2M credentials not found in bootstrap file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Getting Management API token..."
|
||||
TOKEN_RESPONSE=$(curl -s -X POST "${LOGTO_ENDPOINT}/oidc/token" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "grant_type=client_credentials&client_id=${M2M_ID}&client_secret=${M2M_SECRET}&resource=${MGMT_API_RESOURCE}&scope=all")
|
||||
TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token' 2>/dev/null)
|
||||
[ -z "$TOKEN" ] || [ "$TOKEN" = "null" ] && { log "ERROR: Failed to get token"; exit 1; }
|
||||
log "Got Management API token."
|
||||
|
||||
api_get() { curl -s -H "Authorization: Bearer $TOKEN" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || echo "[]"; }
|
||||
api_post() { curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d "$2" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true; }
|
||||
|
||||
# ============================================================
|
||||
# Create saas-vendor global role
|
||||
# ============================================================
|
||||
|
||||
log "Checking for saas-vendor role..."
|
||||
EXISTING_ROLES=$(api_get "/api/roles")
|
||||
VENDOR_ROLE_ID=$(echo "$EXISTING_ROLES" | jq -r '.[] | select(.name == "saas-vendor" and .type == "User") | .id')
|
||||
|
||||
if [ -n "$VENDOR_ROLE_ID" ]; then
|
||||
log "saas-vendor role exists: $VENDOR_ROLE_ID"
|
||||
else
|
||||
# Collect all API resource scope IDs
|
||||
EXISTING_RESOURCES=$(api_get "/api/resources")
|
||||
API_RESOURCE_ID=$(echo "$EXISTING_RESOURCES" | jq -r ".[] | select(.indicator == \"$API_RESOURCE_INDICATOR\") | .id")
|
||||
ALL_SCOPE_IDS=$(api_get "/api/resources/$API_RESOURCE_ID/scopes" | jq '[.[].id]')
|
||||
|
||||
log "Creating saas-vendor role with all scopes..."
|
||||
VENDOR_ROLE_RESPONSE=$(api_post "/api/roles" "{
|
||||
\"name\": \"saas-vendor\",
|
||||
\"description\": \"SaaS vendor — full platform control across all tenants\",
|
||||
\"type\": \"User\",
|
||||
\"scopeIds\": $ALL_SCOPE_IDS
|
||||
}")
|
||||
VENDOR_ROLE_ID=$(echo "$VENDOR_ROLE_RESPONSE" | jq -r '.id')
|
||||
log "Created saas-vendor role: $VENDOR_ROLE_ID"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# Create vendor user
|
||||
# ============================================================
|
||||
|
||||
log "Checking for vendor user '$VENDOR_USER'..."
|
||||
VENDOR_USER_ID=$(api_get "/api/users?search=$VENDOR_USER" | jq -r ".[] | select(.username == \"$VENDOR_USER\") | .id")
|
||||
|
||||
if [ -n "$VENDOR_USER_ID" ]; then
|
||||
log "Vendor user exists: $VENDOR_USER_ID"
|
||||
else
|
||||
log "Creating vendor user '$VENDOR_USER'..."
|
||||
VENDOR_RESPONSE=$(api_post "/api/users" "{
|
||||
\"username\": \"$VENDOR_USER\",
|
||||
\"password\": \"$VENDOR_PASS\",
|
||||
\"name\": \"$VENDOR_NAME\"
|
||||
}")
|
||||
VENDOR_USER_ID=$(echo "$VENDOR_RESPONSE" | jq -r '.id')
|
||||
log "Created vendor user: $VENDOR_USER_ID"
|
||||
fi
|
||||
|
||||
# Assign saas-vendor role
|
||||
if [ -n "$VENDOR_ROLE_ID" ] && [ "$VENDOR_ROLE_ID" != "null" ]; then
|
||||
api_post "/api/users/$VENDOR_USER_ID/roles" "{\"roleIds\": [\"$VENDOR_ROLE_ID\"]}" >/dev/null 2>&1
|
||||
log "Assigned saas-vendor role."
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# Add vendor to all existing organizations with owner role
|
||||
# ============================================================
|
||||
|
||||
log "Adding vendor to all organizations..."
|
||||
ORG_OWNER_ROLE_ID=$(api_get "/api/organization-roles" | jq -r '.[] | select(.name == "owner") | .id')
|
||||
ORGS=$(api_get "/api/organizations")
|
||||
ORG_COUNT=$(echo "$ORGS" | jq 'length')
|
||||
|
||||
for i in $(seq 0 $((ORG_COUNT - 1))); do
|
||||
ORG_ID=$(echo "$ORGS" | jq -r ".[$i].id")
|
||||
ORG_NAME=$(echo "$ORGS" | jq -r ".[$i].name")
|
||||
api_post "/api/organizations/$ORG_ID/users" "{\"userIds\": [\"$VENDOR_USER_ID\"]}" >/dev/null 2>&1
|
||||
if [ -n "$ORG_OWNER_ROLE_ID" ] && [ "$ORG_OWNER_ROLE_ID" != "null" ]; then
|
||||
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d "{\"organizationRoleIds\": [\"$ORG_OWNER_ROLE_ID\"]}" \
|
||||
"${LOGTO_ENDPOINT}/api/organizations/$ORG_ID/users/$VENDOR_USER_ID/roles" >/dev/null 2>&1
|
||||
fi
|
||||
log " Added to org '$ORG_NAME' ($ORG_ID) with owner role."
|
||||
done
|
||||
|
||||
log ""
|
||||
log "=== Vendor seed complete! ==="
|
||||
log " Vendor user: $VENDOR_USER / $VENDOR_PASS"
|
||||
log " Role: saas-vendor (global) + owner (in all orgs)"
|
||||
log " This user has platform:admin scope and cross-tenant access."
|
||||
@@ -15,12 +15,12 @@ infrastructure themselves.
|
||||
|
||||
The system comprises three components:
|
||||
|
||||
**Cameleer Agent** (`cameleer3` repo) -- A Java agent using ByteBuddy for
|
||||
**Cameleer Agent** (`cameleer` repo) -- A Java agent using ByteBuddy for
|
||||
zero-code bytecode instrumentation. Captures route executions, processor traces,
|
||||
payloads, metrics, and route graph topology. Deployed as a `-javaagent` JAR
|
||||
alongside the customer's application.
|
||||
|
||||
**Cameleer Server** (`cameleer3-server` repo) -- A Spring Boot observability
|
||||
**Cameleer Server** (`cameleer-server` repo) -- A Spring Boot observability
|
||||
backend. Receives telemetry from agents via HTTP, pushes configuration and
|
||||
commands to agents via SSE. Stores data in PostgreSQL and ClickHouse. Provides
|
||||
a React SPA dashboard for direct observability access. JWT auth with Ed25519
|
||||
@@ -50,7 +50,7 @@ logging. Serves a React SPA that wraps the full user experience.
|
||||
| | /interaction) |
|
||||
v v v v
|
||||
+--------------+ +--------------+ +-----------+ +------------------+
|
||||
| cameleer-saas| | cameleer-saas| | Logto | | cameleer3-server |
|
||||
| cameleer-saas| | cameleer-saas| | Logto | | cameleer-server |
|
||||
| (API) | | (SPA) | | | | |
|
||||
| :8080 | | :8080 | | :3001 | | :8081 |
|
||||
+--------------+ +--------------+ +-----------+ +------------------+
|
||||
@@ -80,14 +80,14 @@ logging. Serves a React SPA that wraps the full user experience.
|
||||
| logto | `ghcr.io/logto-io/logto:latest` | 3001 | cameleer | OIDC identity provider |
|
||||
| logto-bootstrap | `postgres:16-alpine` (ephemeral) | -- | cameleer | One-shot bootstrap script |
|
||||
| cameleer-saas | `gitea.siegeln.net/cameleer/cameleer-saas` | 8080 | cameleer | SaaS API + SPA serving |
|
||||
| cameleer3-server | `gitea.siegeln.net/cameleer/cameleer3-server`| 8081 | cameleer | Observability backend |
|
||||
| cameleer-server | `gitea.siegeln.net/cameleer/cameleer-server`| 8081 | cameleer | Observability backend |
|
||||
| clickhouse | `clickhouse/clickhouse-server:latest` | 8123 | cameleer | Time-series telemetry storage |
|
||||
|
||||
### Docker Network
|
||||
|
||||
All services share a single Docker bridge network named `cameleer`. Customer app
|
||||
containers are also attached to this network so agents can reach the
|
||||
cameleer3-server.
|
||||
cameleer-server.
|
||||
|
||||
### Volumes
|
||||
|
||||
@@ -105,7 +105,7 @@ The shared PostgreSQL instance hosts three databases:
|
||||
|
||||
- `cameleer_saas` -- SaaS platform tables (tenants, environments, apps, etc.)
|
||||
- `logto` -- Logto identity provider data
|
||||
- `cameleer3` -- cameleer3-server operational data
|
||||
- `cameleer` -- cameleer-server operational data
|
||||
|
||||
The `docker/init-databases.sh` init script creates all three during first start.
|
||||
|
||||
@@ -128,9 +128,9 @@ The `docker/init-databases.sh` init script creates all three during first start.
|
||||
|--------------------|-----------------|------------------|----------------------|--------------------------------|
|
||||
| Logto user JWT | Logto | ES384 (asymmetric)| Any service via JWKS | SaaS UI users, server users |
|
||||
| Logto M2M JWT | Logto | ES384 (asymmetric)| Any service via JWKS | SaaS platform -> server calls |
|
||||
| Server internal JWT| cameleer3-server| HS256 (symmetric) | Issuing server only | Agents (after registration) |
|
||||
| API key (opaque) | SaaS platform | N/A (SHA-256 hash)| cameleer3-server | Agent initial registration |
|
||||
| Ed25519 signature | cameleer3-server| EdDSA | Agent | Server -> agent command signing|
|
||||
| Server internal JWT| cameleer-server| HS256 (symmetric) | Issuing server only | Agents (after registration) |
|
||||
| API key (opaque) | SaaS platform | N/A (SHA-256 hash)| cameleer-server | Agent initial registration |
|
||||
| Ed25519 signature | cameleer-server| EdDSA | Agent | Server -> agent command signing|
|
||||
|
||||
### 3.3 Scope Model
|
||||
|
||||
@@ -183,7 +183,7 @@ the bootstrap script (`docker/logto-bootstrap.sh`):
|
||||
4. `organization_id` claim in JWT resolves to internal tenant ID via
|
||||
`TenantIsolationInterceptor`.
|
||||
|
||||
**SaaS platform -> cameleer3-server API (M2M):**
|
||||
**SaaS platform -> cameleer-server API (M2M):**
|
||||
|
||||
1. SaaS platform obtains Logto M2M token (`client_credentials` grant) via
|
||||
`LogtoManagementClient`.
|
||||
@@ -191,9 +191,9 @@ the bootstrap script (`docker/logto-bootstrap.sh`):
|
||||
3. Server validates via Logto JWKS (OIDC resource server support).
|
||||
4. Server grants ADMIN role to valid M2M tokens.
|
||||
|
||||
**Agent -> cameleer3-server:**
|
||||
**Agent -> cameleer-server:**
|
||||
|
||||
1. Agent reads `CAMELEER_AUTH_TOKEN` environment variable (API key).
|
||||
1. Agent reads `CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKEN` environment variable (API key).
|
||||
2. Calls `POST /api/v1/agents/register` with the key as Bearer token.
|
||||
3. Server validates via `BootstrapTokenValidator` (constant-time comparison).
|
||||
4. Server issues internal HMAC JWT (access + refresh) + Ed25519 public key.
|
||||
@@ -458,9 +458,9 @@ Defined in `AuditAction.java`:
|
||||
|
||||
### 5.1 Server-Per-Tenant
|
||||
|
||||
Each tenant gets a dedicated cameleer3-server instance. The SaaS platform
|
||||
Each tenant gets a dedicated cameleer-server instance. The SaaS platform
|
||||
provisions and manages these servers. In the current Docker Compose topology, a
|
||||
single shared cameleer3-server is used for the default tenant. Production
|
||||
single shared cameleer-server is used for the default tenant. Production
|
||||
deployments will run per-tenant servers as separate containers or K8s pods.
|
||||
|
||||
### 5.2 Customer App Deployment Flow
|
||||
@@ -493,9 +493,9 @@ The deployment lifecycle is managed by `DeploymentService`:
|
||||
|
||||
| Variable | Value |
|
||||
|-----------------------------|----------------------------------------|
|
||||
| `CAMELEER_AUTH_TOKEN` | API key for agent registration |
|
||||
| `CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKEN` | API key for agent registration |
|
||||
| `CAMELEER_EXPORT_TYPE` | `HTTP` |
|
||||
| `CAMELEER_SERVER_URL` | cameleer3-server internal URL |
|
||||
| `CAMELEER_SERVER_RUNTIME_SERVERURL` | cameleer-server internal URL |
|
||||
| `CAMELEER_APPLICATION_ID` | App slug |
|
||||
| `CAMELEER_ENVIRONMENT_ID` | Environment slug |
|
||||
| `CAMELEER_DISPLAY_NAME` | `{tenant}-{env}-{app}` |
|
||||
@@ -524,14 +524,14 @@ Configured via `RuntimeConfig`:
|
||||
## 6. Agent-Server Protocol
|
||||
|
||||
The agent-server protocol is defined in full in
|
||||
`cameleer3/cameleer3-common/PROTOCOL.md`. This section summarizes the key
|
||||
`cameleer/cameleer-common/PROTOCOL.md`. This section summarizes the key
|
||||
aspects relevant to the SaaS platform.
|
||||
|
||||
### 6.1 Agent Registration
|
||||
|
||||
1. Agent starts with `CAMELEER_AUTH_TOKEN` environment variable (an API key
|
||||
1. Agent starts with `CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKEN` environment variable (an API key
|
||||
generated by the SaaS platform, prefixed with `cmk_`).
|
||||
2. Agent calls `POST /api/v1/agents/register` on the cameleer3-server with the
|
||||
2. Agent calls `POST /api/v1/agents/register` on the cameleer-server with the
|
||||
API key as a Bearer token.
|
||||
3. Server validates the key and returns:
|
||||
- HMAC JWT access token (short-lived, ~1 hour)
|
||||
@@ -744,7 +744,7 @@ leaks regardless of whether the request succeeded or failed.
|
||||
|----------------------|-------------|------------------------------------|
|
||||
| Logto access token | ~1 hour | Configured in Logto, refreshed by SDK |
|
||||
| Logto refresh token | ~14 days | Used by `@logto/react` for silent refresh |
|
||||
| Server agent JWT | ~1 hour | cameleer3-server `CAMELEER_JWT_SECRET` |
|
||||
| Server agent JWT | ~1 hour | cameleer-server `CAMELEER_JWT_SECRET` |
|
||||
| Server refresh token | ~7 days | Agent re-registers when expired |
|
||||
|
||||
### 8.4 Audit Logging
|
||||
@@ -858,51 +858,61 @@ state (`currentTenantId`). Provides `logout` and `signIn` callbacks.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|------------------------------|----------------------------------------------|----------------------------------|
|
||||
| `SPRING_DATASOURCE_URL` | `jdbc:postgresql://postgres:5432/cameleer_saas` | PostgreSQL JDBC URL |
|
||||
| `SPRING_DATASOURCE_URL` | `jdbc:postgresql://cameleer-postgres:5432/cameleer_saas` | PostgreSQL JDBC URL |
|
||||
| `SPRING_DATASOURCE_USERNAME`| `cameleer` | PostgreSQL user |
|
||||
| `SPRING_DATASOURCE_PASSWORD`| `cameleer_dev` | PostgreSQL password |
|
||||
|
||||
**Logto / OIDC:**
|
||||
**Identity / OIDC:**
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---------------------------|------------|--------------------------------------------|
|
||||
| `LOGTO_ENDPOINT` | (empty) | Logto internal URL (Docker-internal) |
|
||||
| `LOGTO_PUBLIC_ENDPOINT` | (empty) | Logto public URL (browser-accessible) |
|
||||
| `LOGTO_ISSUER_URI` | (empty) | OIDC issuer URI for JWT validation |
|
||||
| `LOGTO_JWK_SET_URI` | (empty) | JWKS endpoint for JWT signature validation |
|
||||
| `LOGTO_M2M_CLIENT_ID` | (empty) | M2M app client ID (from bootstrap) |
|
||||
| `LOGTO_M2M_CLIENT_SECRET` | (empty) | M2M app client secret (from bootstrap) |
|
||||
| `LOGTO_SPA_CLIENT_ID` | (empty) | SPA app client ID (fallback; bootstrap preferred) |
|
||||
| `CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT` | (empty) | Logto internal URL (Docker-internal) |
|
||||
| `CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT` | (empty) | Logto public URL (browser-accessible) |
|
||||
| `CAMELEER_SAAS_IDENTITY_M2MCLIENTID` | (empty) | M2M app client ID (from bootstrap) |
|
||||
| `CAMELEER_SAAS_IDENTITY_M2MCLIENTSECRET` | (empty) | M2M app client secret (from bootstrap) |
|
||||
| `CAMELEER_SAAS_IDENTITY_SPACLIENTID` | (empty) | SPA app client ID (fallback; bootstrap preferred) |
|
||||
|
||||
**Runtime / Deployment:**
|
||||
**Provisioning** (`cameleer.saas.provisioning.*` / `CAMELEER_SAAS_PROVISIONING_*`):
|
||||
|
||||
| Variable | Default | Description |
|
||||
|-----------------------------------|------------------------------------|----------------------------------|
|
||||
| `CAMELEER3_SERVER_ENDPOINT` | `http://cameleer3-server:8081` | cameleer3-server internal URL |
|
||||
| `CAMELEER_JAR_STORAGE_PATH` | `/data/jars` | JAR upload storage directory |
|
||||
| `CAMELEER_RUNTIME_BASE_IMAGE` | `cameleer-runtime-base:latest` | Base Docker image for app builds |
|
||||
| `CAMELEER_DOCKER_NETWORK` | `cameleer` | Docker network for containers |
|
||||
| `CAMELEER_CONTAINER_MEMORY_LIMIT`| `512m` | Per-container memory limit |
|
||||
| `CAMELEER_CONTAINER_CPU_SHARES` | `512` | Per-container CPU shares |
|
||||
| `CLICKHOUSE_URL` | `jdbc:clickhouse://clickhouse:8123/cameleer` | ClickHouse JDBC URL |
|
||||
| `CLICKHOUSE_ENABLED` | `true` | Enable ClickHouse integration |
|
||||
| `CLICKHOUSE_USERNAME` | `default` | ClickHouse user |
|
||||
| `CLICKHOUSE_PASSWORD` | (empty) | ClickHouse password |
|
||||
| `DOMAIN` | `localhost` | Base domain for Traefik routing |
|
||||
| `CAMELEER_SAAS_PROVISIONING_SERVERIMAGE` | `gitea.siegeln.net/cameleer/cameleer-server:latest` | Docker image for per-tenant server |
|
||||
| `CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE` | `gitea.siegeln.net/cameleer/cameleer-server-ui:latest` | Docker image for per-tenant UI |
|
||||
| `CAMELEER_SAAS_PROVISIONING_NETWORKNAME` | `cameleer-saas_cameleer` | Shared services Docker network |
|
||||
| `CAMELEER_SAAS_PROVISIONING_TRAEFIKNETWORK` | `cameleer-traefik` | Traefik Docker network |
|
||||
| `CAMELEER_SAAS_PROVISIONING_PUBLICHOST` | `localhost` | Public hostname (same as infrastructure `PUBLIC_HOST`) |
|
||||
| `CAMELEER_SAAS_PROVISIONING_PUBLICPROTOCOL` | `https` | Public protocol (same as infrastructure `PUBLIC_PROTOCOL`) |
|
||||
| `CAMELEER_SAAS_PROVISIONING_DATASOURCEURL` | `jdbc:postgresql://cameleer-postgres:5432/cameleer` | PostgreSQL URL passed to tenant servers |
|
||||
| `CAMELEER_SAAS_PROVISIONING_CLICKHOUSEURL` | `jdbc:clickhouse://cameleer-clickhouse:8123/cameleer` | ClickHouse URL passed to tenant servers |
|
||||
|
||||
### 10.2 cameleer3-server
|
||||
### 10.2 cameleer-server (per-tenant)
|
||||
|
||||
| Variable | Default | Description |
|
||||
Env vars injected into provisioned per-tenant server containers by `DockerTenantProvisioner`. All server properties use the `cameleer.server.*` prefix (env vars: `CAMELEER_SERVER_*`).
|
||||
|
||||
| Variable | Default / Value | Description |
|
||||
|------------------------------|----------------------------------------------|----------------------------------|
|
||||
| `SPRING_DATASOURCE_URL` | `jdbc:postgresql://postgres:5432/cameleer3` | PostgreSQL JDBC URL |
|
||||
| `SPRING_DATASOURCE_URL` | `jdbc:postgresql://cameleer-postgres:5432/cameleer` | PostgreSQL JDBC URL |
|
||||
| `SPRING_DATASOURCE_USERNAME`| `cameleer` | PostgreSQL user |
|
||||
| `SPRING_DATASOURCE_PASSWORD`| `cameleer_dev` | PostgreSQL password |
|
||||
| `CLICKHOUSE_URL` | `jdbc:clickhouse://clickhouse:8123/cameleer` | ClickHouse JDBC URL |
|
||||
| `CAMELEER_AUTH_TOKEN` | `default-bootstrap-token` | Agent bootstrap token |
|
||||
| `CAMELEER_SERVER_CLICKHOUSE_URL` | `jdbc:clickhouse://cameleer-clickhouse:8123/cameleer` | ClickHouse JDBC URL |
|
||||
| `CAMELEER_SERVER_TENANT_ID` | *(tenant slug)* | Tenant identifier for data isolation |
|
||||
| `CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKEN` | *(generated)* | Agent bootstrap token |
|
||||
| `CAMELEER_SERVER_SECURITY_JWTSECRET` | *(generated, must be non-empty)* | JWT signing secret |
|
||||
| `CAMELEER_SERVER_SECURITY_OIDC_ISSUERURI` | `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/oidc` | OIDC issuer for M2M tokens |
|
||||
| `CAMELEER_SERVER_SECURITY_OIDC_JWKSETURI` | `http://cameleer-logto:3001/oidc/jwks` | Docker-internal JWK fetch |
|
||||
| `CAMELEER_SERVER_SECURITY_OIDC_AUDIENCE` | `https://api.cameleer.local` | JWT audience validation |
|
||||
| `CAMELEER_SERVER_SECURITY_CORSALLOWEDORIGINS` | `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}` | CORS for browser requests |
|
||||
| `CAMELEER_SERVER_RUNTIME_ENABLED` | `true` | Enable Docker orchestration |
|
||||
| `CAMELEER_SERVER_RUNTIME_SERVERURL` | `http://cameleer-server-{slug}:8081` | Per-tenant server URL |
|
||||
| `CAMELEER_SERVER_RUNTIME_ROUTINGDOMAIN` | `${PUBLIC_HOST}` | Domain for Traefik routing |
|
||||
| `CAMELEER_SERVER_RUNTIME_ROUTINGMODE` | `path` | `path` or `subdomain` routing |
|
||||
| `CAMELEER_SERVER_RUNTIME_JARSTORAGEPATH` | `/data/jars` | JAR file storage directory |
|
||||
| `CAMELEER_SERVER_RUNTIME_DOCKERNETWORK` | `cameleer-tenant-{slug}` | Primary network for app containers |
|
||||
| `CAMELEER_SERVER_RUNTIME_JARDOCKERVOLUME` | `cameleer-jars-{slug}` | Docker volume for JAR sharing |
|
||||
| `CAMELEER_JWT_SECRET` | `cameleer-dev-jwt-secret-...` | HMAC secret for internal JWTs |
|
||||
| `CAMELEER_TENANT_ID` | `default` | Tenant slug for data isolation |
|
||||
| `CAMELEER_OIDC_ISSUER_URI` | (empty) | Logto issuer for M2M token validation |
|
||||
| `CAMELEER_OIDC_AUDIENCE` | (empty) | Expected JWT audience |
|
||||
| `CAMELEER_SERVER_TENANT_ID` | `default` | Tenant slug for data isolation |
|
||||
| `CAMELEER_SERVER_SECURITY_OIDCISSUERURI` | (empty) | Logto issuer for M2M token validation |
|
||||
| `CAMELEER_SERVER_SECURITY_OIDCAUDIENCE` | (empty) | Expected JWT audience |
|
||||
|
||||
### 10.3 logto
|
||||
|
||||
@@ -927,7 +937,7 @@ state (`currentTenantId`). Provides `logout` and `signIn` callbacks.
|
||||
| `SAAS_ADMIN_PASS` | `admin` | Platform admin password |
|
||||
| `TENANT_ADMIN_USER` | `camel` | Default tenant admin username |
|
||||
| `TENANT_ADMIN_PASS` | `camel` | Default tenant admin password |
|
||||
| `CAMELEER_AUTH_TOKEN`| `default-bootstrap-token` | Agent bootstrap token |
|
||||
| `CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKEN`| `default-bootstrap-token` | Agent bootstrap token |
|
||||
|
||||
### 10.6 Bootstrap Output
|
||||
|
||||
@@ -947,7 +957,7 @@ The bootstrap script writes `/data/logto-bootstrap.json` containing:
|
||||
"bootstrapToken": "<from env>",
|
||||
"platformAdminUser": "<from env>",
|
||||
"tenantAdminUser": "<from env>",
|
||||
"oidcIssuerUri": "http://logto:3001/oidc",
|
||||
"oidcIssuerUri": "http://cameleer-logto:3001/oidc",
|
||||
"oidcAudience": "https://api.cameleer.local"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -80,7 +80,7 @@ Note: Phase 9 (Frontend) can be developed in parallel with Phases 3-8, building
|
||||
**PRD Sections:** 6 (Tenant Provisioning), 11 (Networking & Tenant Isolation)
|
||||
**Gitea Epics:** #3 (Tenant Provisioning), #8 (Networking)
|
||||
**Depends on:** Phase 2
|
||||
**Produces:** Automated tenant provisioning pipeline. Signup creates tenant → Flux HelmRelease generated → namespace provisioned → cameleer3-server deployed → PostgreSQL schema + OpenSearch index created → tenant ACTIVE. NetworkPolicies enforced.
|
||||
**Produces:** Automated tenant provisioning pipeline. Signup creates tenant → Flux HelmRelease generated → namespace provisioned → cameleer-server deployed → PostgreSQL schema + OpenSearch index created → tenant ACTIVE. NetworkPolicies enforced.
|
||||
|
||||
**Key deliverables:**
|
||||
- Provisioning state machine (idempotent, retryable)
|
||||
@@ -91,7 +91,7 @@ Note: Phase 9 (Frontend) can be developed in parallel with Phases 3-8, building
|
||||
- Readiness checking (poll tenant server health)
|
||||
- Tenant lifecycle operations (suspend, reactivate, delete)
|
||||
- K8s NetworkPolicy templates (default deny + allow rules)
|
||||
- Helm chart for cameleer3-server tenant deployment
|
||||
- Helm chart for cameleer-server tenant deployment
|
||||
|
||||
---
|
||||
|
||||
@@ -143,11 +143,11 @@ Note: Phase 9 (Frontend) can be developed in parallel with Phases 3-8, building
|
||||
**PRD Sections:** 8 (Observability Integration)
|
||||
**Gitea Epics:** #6 (Observability Integration), #13 (Exchange Replay — gating only)
|
||||
**Depends on:** Phase 3 (server already deployed per tenant), Phase 2 (license for feature gating)
|
||||
**Produces:** Tenants see their cameleer3-server UI embedded in the SaaS shell. API gateway routes to tenant server. MOAT features gated by license tier.
|
||||
**Produces:** Tenants see their cameleer-server UI embedded in the SaaS shell. API gateway routes to tenant server. MOAT features gated by license tier.
|
||||
|
||||
**Key deliverables:**
|
||||
- Ingress routing rules: `/t/{tenant}/api/*` → tenant's cameleer3-server
|
||||
- cameleer3-server "managed mode" configuration (trust SaaS JWT, report metrics)
|
||||
- Ingress routing rules: `/t/{tenant}/api/*` → tenant's cameleer-server
|
||||
- cameleer-server "managed mode" configuration (trust SaaS JWT, report metrics)
|
||||
- Bootstrap token generation API
|
||||
- MOAT feature gating via license (topology=all, lineage=limited/full, correlation=mid+, debugger=high+, replay=high+)
|
||||
- Server UI embedding approach (iframe or reverse proxy with path rewriting)
|
||||
@@ -211,7 +211,7 @@ Note: Phase 9 (Frontend) can be developed in parallel with Phases 3-8, building
|
||||
- SaaS shell (navigation, tenant switcher, user menu)
|
||||
- Dashboard (platform overview)
|
||||
- Apps list + App deployment page (upload, config, secrets, status, logs, versions)
|
||||
- Observability section (embedded cameleer3-server UI)
|
||||
- Observability section (embedded cameleer-server UI)
|
||||
- Team management pages
|
||||
- Settings pages (tenant config, SSO/OIDC, vault connections)
|
||||
- Billing pages (usage, invoices, plan management)
|
||||
|
||||
@@ -2006,7 +2006,7 @@ available throughout request lifecycle."
|
||||
**Files:**
|
||||
- Create: `src/main/java/net/siegeln/cameleer/saas/config/ForwardAuthController.java`
|
||||
|
||||
This endpoint is called by Traefik's ForwardAuth middleware to validate requests routed to non-platform services (e.g., cameleer3-server). It validates the JWT, resolves the tenant, and returns tenant context headers.
|
||||
This endpoint is called by Traefik's ForwardAuth middleware to validate requests routed to non-platform services (e.g., cameleer-server). It validates the JWT, resolves the tenant, and returns tenant context headers.
|
||||
|
||||
- [ ] **Step 1: Create ForwardAuthController**
|
||||
|
||||
@@ -2455,8 +2455,8 @@ services:
|
||||
networks:
|
||||
- cameleer
|
||||
|
||||
cameleer3-server:
|
||||
image: ${CAMELEER3_SERVER_IMAGE:-gitea.siegeln.net/cameleer/cameleer3-server}:${VERSION:-latest}
|
||||
cameleer-server:
|
||||
image: ${CAMELEER_SERVER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-server}:${VERSION:-latest}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
@@ -2539,9 +2539,9 @@ git add docker-compose.yml docker-compose.dev.yml traefik.yml docker/init-databa
|
||||
git commit -m "feat: add Docker Compose production stack with Traefik + Logto
|
||||
|
||||
7-container stack: Traefik (reverse proxy), PostgreSQL (shared),
|
||||
Logto (identity), cameleer-saas (control plane), cameleer3-server
|
||||
Logto (identity), cameleer-saas (control plane), cameleer-server
|
||||
(observability), ClickHouse (traces). ForwardAuth middleware for
|
||||
tenant-aware routing to cameleer3-server."
|
||||
tenant-aware routing to cameleer-server."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Customers can upload a Camel JAR, the platform builds a container image with cameleer3 agent auto-injected, and deploys it to a logical environment with full lifecycle management.
|
||||
**Goal:** Customers can upload a Camel JAR, the platform builds a container image with cameleer agent auto-injected, and deploys it to a logical environment with full lifecycle management.
|
||||
|
||||
**Architecture:** Environment → App → Deployment entity hierarchy. `RuntimeOrchestrator` interface with `DockerRuntimeOrchestrator` (docker-java) implementation. Async deployment pipeline with status polling. Container logs streamed to ClickHouse. Pre-built `cameleer-runtime-base` image for fast (~1-3s) customer image builds.
|
||||
|
||||
@@ -164,8 +164,8 @@ public class RuntimeConfig {
|
||||
@Value("${cameleer.runtime.bootstrap-token:${CAMELEER_AUTH_TOKEN:}}")
|
||||
private String bootstrapToken;
|
||||
|
||||
@Value("${cameleer.runtime.cameleer3-server-endpoint:http://cameleer3-server:8081}")
|
||||
private String cameleer3ServerEndpoint;
|
||||
@Value("${cameleer.runtime.cameleer-server-endpoint:http://cameleer-server:8081}")
|
||||
private String cameleerServerEndpoint;
|
||||
|
||||
public long getMaxJarSize() { return maxJarSize; }
|
||||
public String getJarStoragePath() { return jarStoragePath; }
|
||||
@@ -177,7 +177,7 @@ public class RuntimeConfig {
|
||||
public String getContainerMemoryLimit() { return containerMemoryLimit; }
|
||||
public int getContainerCpuShares() { return containerCpuShares; }
|
||||
public String getBootstrapToken() { return bootstrapToken; }
|
||||
public String getCameleer3ServerEndpoint() { return cameleer3ServerEndpoint; }
|
||||
public String getCameleerServerEndpoint() { return cameleerServerEndpoint; }
|
||||
|
||||
public long parseMemoryLimitBytes() {
|
||||
var limit = containerMemoryLimit.trim().toLowerCase();
|
||||
@@ -270,7 +270,7 @@ Append to the existing `cameleer:` section in `src/main/resources/application.ym
|
||||
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}
|
||||
cameleer-server-endpoint: ${CAMELEER_SERVER_ENDPOINT:http://cameleer-server:8081}
|
||||
clickhouse:
|
||||
url: ${CLICKHOUSE_URL:jdbc:clickhouse://clickhouse:8123/cameleer}
|
||||
```
|
||||
@@ -2788,7 +2788,7 @@ public class DeploymentService {
|
||||
var envVars = Map.of(
|
||||
"CAMELEER_AUTH_TOKEN", env.getBootstrapToken(),
|
||||
"CAMELEER_EXPORT_TYPE", "HTTP",
|
||||
"CAMELEER_EXPORT_ENDPOINT", runtimeConfig.getCameleer3ServerEndpoint(),
|
||||
"CAMELEER_EXPORT_ENDPOINT", runtimeConfig.getCameleerServerEndpoint(),
|
||||
"CAMELEER_APPLICATION_ID", app.getSlug(),
|
||||
"CAMELEER_ENVIRONMENT_ID", env.getSlug(),
|
||||
"CAMELEER_DISPLAY_NAME", containerName);
|
||||
@@ -3418,7 +3418,7 @@ volumes:
|
||||
Add to the cameleer-saas service environment:
|
||||
```yaml
|
||||
CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}
|
||||
CAMELEER3_SERVER_ENDPOINT: http://cameleer3-server:8081
|
||||
CAMELEER_SERVER_ENDPOINT: http://cameleer-server:8081
|
||||
CLICKHOUSE_URL: jdbc:clickhouse://clickhouse:8123/cameleer
|
||||
```
|
||||
|
||||
@@ -3427,7 +3427,7 @@ Add to the cameleer-saas service volumes:
|
||||
- jardata:/data/jars
|
||||
```
|
||||
|
||||
Add `CAMELEER_AUTH_TOKEN` to the cameleer3-server service environment:
|
||||
Add `CAMELEER_AUTH_TOKEN` to the cameleer-server service environment:
|
||||
```yaml
|
||||
CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}
|
||||
```
|
||||
@@ -3448,7 +3448,7 @@ 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
|
||||
# ARG AGENT_JAR=cameleer-agent-1.0-SNAPSHOT-shaded.jar
|
||||
COPY agent.jar /app/agent.jar
|
||||
|
||||
ENTRYPOINT exec java \
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Complete the deploy → hit endpoint → see traces loop. Serve the existing cameleer3-server dashboard, add agent connectivity verification, enable optional inbound HTTP routing for customer apps, and wire up observability data health checks.
|
||||
**Goal:** Complete the deploy → hit endpoint → see traces loop. Serve the existing cameleer-server dashboard, add agent connectivity verification, enable optional inbound HTTP routing for customer apps, and wire up observability data health checks.
|
||||
|
||||
**Architecture:** Wiring phase — cameleer3-server already has full observability. Phase 4 adds Traefik routing for the dashboard + customer app endpoints, new API endpoints in cameleer-saas for agent-status and observability-status, and configures `CAMELEER_TENANT_ID` on the server.
|
||||
**Architecture:** Wiring phase — cameleer-server already has full observability. Phase 4 adds Traefik routing for the dashboard + customer app endpoints, new API endpoints in cameleer-saas for agent-status and observability-status, and configures `CAMELEER_TENANT_ID` on the server.
|
||||
|
||||
**Tech Stack:** Spring Boot 3.4.3, docker-java 3.4.1, ClickHouse JDBC, Traefik v3 labels, Spring RestClient
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
### New Files
|
||||
|
||||
- `src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusService.java` — Queries cameleer3-server for agent registration
|
||||
- `src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusService.java` — Queries cameleer-server for agent registration
|
||||
- `src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusController.java` — Agent status + observability status endpoints
|
||||
- `src/main/java/net/siegeln/cameleer/saas/observability/dto/AgentStatusResponse.java` — Response DTO
|
||||
- `src/main/java/net/siegeln/cameleer/saas/observability/dto/ObservabilityStatusResponse.java` — Response DTO
|
||||
@@ -359,7 +359,7 @@ class AgentStatusServiceTest {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
when(runtimeConfig.getCameleer3ServerEndpoint()).thenReturn("http://cameleer3-server:8081");
|
||||
when(runtimeConfig.getCameleerServerEndpoint()).thenReturn("http://cameleer-server:8081");
|
||||
agentStatusService = new AgentStatusService(appRepository, environmentRepository, runtimeConfig);
|
||||
}
|
||||
|
||||
@@ -439,7 +439,7 @@ public class AgentStatusService {
|
||||
this.environmentRepository = environmentRepository;
|
||||
this.runtimeConfig = runtimeConfig;
|
||||
this.restClient = RestClient.builder()
|
||||
.baseUrl(runtimeConfig.getCameleer3ServerEndpoint())
|
||||
.baseUrl(runtimeConfig.getCameleerServerEndpoint())
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -475,7 +475,7 @@ public class AgentStatusService {
|
||||
return new AgentStatusResponse(false, "NOT_REGISTERED", null,
|
||||
List.of(), app.getSlug(), env.getSlug());
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to query agent status from cameleer3-server: {}", e.getMessage());
|
||||
log.warn("Failed to query agent status from cameleer-server: {}", e.getMessage());
|
||||
return new AgentStatusResponse(false, "UNKNOWN", null,
|
||||
List.of(), app.getSlug(), env.getSlug());
|
||||
}
|
||||
@@ -651,28 +651,28 @@ public class ConnectivityHealthCheck {
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void verifyConnectivity() {
|
||||
checkCameleer3Server();
|
||||
checkCameleerServer();
|
||||
}
|
||||
|
||||
private void checkCameleer3Server() {
|
||||
private void checkCameleerServer() {
|
||||
try {
|
||||
var client = RestClient.builder()
|
||||
.baseUrl(runtimeConfig.getCameleer3ServerEndpoint())
|
||||
.baseUrl(runtimeConfig.getCameleerServerEndpoint())
|
||||
.build();
|
||||
var response = client.get()
|
||||
.uri("/actuator/health")
|
||||
.retrieve()
|
||||
.toBodilessEntity();
|
||||
if (response.getStatusCode().is2xxSuccessful()) {
|
||||
log.info("cameleer3-server connectivity: OK ({})",
|
||||
runtimeConfig.getCameleer3ServerEndpoint());
|
||||
log.info("cameleer-server connectivity: OK ({})",
|
||||
runtimeConfig.getCameleerServerEndpoint());
|
||||
} else {
|
||||
log.warn("cameleer3-server connectivity: HTTP {} ({})",
|
||||
response.getStatusCode(), runtimeConfig.getCameleer3ServerEndpoint());
|
||||
log.warn("cameleer-server connectivity: HTTP {} ({})",
|
||||
response.getStatusCode(), runtimeConfig.getCameleerServerEndpoint());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("cameleer3-server connectivity: FAILED ({}) - {}",
|
||||
runtimeConfig.getCameleer3ServerEndpoint(), e.getMessage());
|
||||
log.warn("cameleer-server connectivity: FAILED ({}) - {}",
|
||||
runtimeConfig.getCameleerServerEndpoint(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -686,7 +686,7 @@ Run: `mvn compile -B -q`
|
||||
|
||||
```bash
|
||||
git add src/main/java/net/siegeln/cameleer/saas/observability/ConnectivityHealthCheck.java
|
||||
git commit -m "feat: add cameleer3-server startup connectivity check"
|
||||
git commit -m "feat: add cameleer-server startup connectivity check"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -700,7 +700,7 @@ git commit -m "feat: add cameleer3-server startup connectivity check"
|
||||
|
||||
- [ ] **Step 1: Update docker-compose.yml — add dashboard route and CAMELEER_TENANT_ID**
|
||||
|
||||
In the `cameleer3-server` service:
|
||||
In the `cameleer-server` service:
|
||||
|
||||
Add to environment section:
|
||||
```yaml
|
||||
@@ -774,7 +774,7 @@ git commit -m "docs: update HOWTO with observability dashboard, routing, and age
|
||||
|
||||
| Spec Requirement | Task |
|
||||
|---|---|
|
||||
| Serve cameleer3-server dashboard via Traefik | Task 7 (dashboard Traefik labels) |
|
||||
| Serve cameleer-server dashboard via Traefik | Task 7 (dashboard Traefik labels) |
|
||||
| CAMELEER_TENANT_ID configuration | Task 7 (docker-compose env) |
|
||||
| Agent connectivity verification endpoint | Task 4 (AgentStatusService + Controller) |
|
||||
| Observability data health endpoint | Task 4 (ObservabilityStatusResponse) |
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**Goal:** Build a React SPA for managing tenants, environments, apps, and deployments. All backend APIs exist — this is the UI layer.
|
||||
|
||||
**Architecture:** React 19 + Vite + React Router + Zustand + TanStack Query + @cameleer/design-system. Sidebar layout matching cameleer3-server SPA. Shared Logto OIDC session. RBAC on all actions. Lives in `ui/` directory, built into Spring Boot static resources.
|
||||
**Architecture:** React 19 + Vite + React Router + Zustand + TanStack Query + @cameleer/design-system. Sidebar layout matching cameleer-server SPA. Shared Logto OIDC session. RBAC on all actions. Lives in `ui/` directory, built into Spring Boot static resources.
|
||||
|
||||
**Tech Stack:** React 19, Vite 8, TypeScript, React Router 7, Zustand, TanStack React Query, @cameleer/design-system 0.1.31, Lucide React
|
||||
|
||||
@@ -332,7 +332,7 @@ git commit -m "feat: scaffold React SPA with Vite, design system, and TypeScript
|
||||
|
||||
- [ ] **Step 1: Create auth-store.ts**
|
||||
|
||||
Zustand store for auth state. Same localStorage keys as cameleer3-server SPA for SSO.
|
||||
Zustand store for auth state. Same localStorage keys as cameleer-server SPA for SSO.
|
||||
|
||||
```typescript
|
||||
import { create } from 'zustand';
|
||||
@@ -1145,7 +1145,7 @@ git commit -m "feat: add SPA controller, Traefik route, CI frontend build, and H
|
||||
|---|---|
|
||||
| Project scaffolding (Vite, React, TS, design system) | Task 1 |
|
||||
| TypeScript API types | Task 1 |
|
||||
| Auth store (Zustand, same keys as cameleer3-server) | Task 2 |
|
||||
| Auth store (Zustand, same keys as cameleer-server) | Task 2 |
|
||||
| Login / Logto OIDC redirect / callback | Task 2 |
|
||||
| Protected route | Task 2 |
|
||||
| API client with auth middleware | Task 3 |
|
||||
|
||||
@@ -2,35 +2,35 @@
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the incoherent three-system auth in cameleer-saas with Logto-centric architecture, and add OIDC resource server support to cameleer3-server for M2M.
|
||||
**Goal:** Replace the incoherent three-system auth in cameleer-saas with Logto-centric architecture, and add OIDC resource server support to cameleer-server for M2M.
|
||||
|
||||
**Architecture:** Logto is the single identity provider for all humans. Spring OAuth2 Resource Server validates Logto JWTs in both the SaaS platform and cameleer3-server. Agents authenticate with per-environment API keys exchanged for server-issued JWTs. Ed25519 command signing is unchanged. Zero trust: every service validates tokens independently via JWKS.
|
||||
**Architecture:** Logto is the single identity provider for all humans. Spring OAuth2 Resource Server validates Logto JWTs in both the SaaS platform and cameleer-server. Agents authenticate with per-environment API keys exchanged for server-issued JWTs. Ed25519 command signing is unchanged. Zero trust: every service validates tokens independently via JWKS.
|
||||
|
||||
**Tech Stack:** Spring Boot 3.4, Spring Security OAuth2 Resource Server, Nimbus JOSE+JWT, Logto, React + @logto/react, Zustand, PostgreSQL, Flyway
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-05-auth-overhaul-design.md`
|
||||
|
||||
**Repos:**
|
||||
- cameleer3-server: `C:\Users\Hendrik\Documents\projects\cameleer3-server` (Phase 1)
|
||||
- cameleer-server: `C:\Users\Hendrik\Documents\projects\cameleer-server` (Phase 1)
|
||||
- cameleer-saas: `C:\Users\Hendrik\Documents\projects\cameleer-saas` (Phases 2-3)
|
||||
- cameleer3 (agent): NO CHANGES
|
||||
- cameleer (agent): NO CHANGES
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: cameleer3-server — OIDC Resource Server Support
|
||||
## Phase 1: cameleer-server — OIDC Resource Server Support
|
||||
|
||||
All Phase 1 work is in `C:\Users\Hendrik\Documents\projects\cameleer3-server`.
|
||||
All Phase 1 work is in `C:\Users\Hendrik\Documents\projects\cameleer-server`.
|
||||
|
||||
### Task 1: Add OAuth2 Resource Server dependency and config properties
|
||||
|
||||
**Files:**
|
||||
- Modify: `cameleer3-server-app/pom.xml`
|
||||
- Modify: `cameleer3-server-app/src/main/resources/application.yml`
|
||||
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java`
|
||||
- Modify: `cameleer-server-app/pom.xml`
|
||||
- Modify: `cameleer-server-app/src/main/resources/application.yml`
|
||||
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityProperties.java`
|
||||
|
||||
- [ ] **Step 1: Add dependency to pom.xml**
|
||||
|
||||
In `cameleer3-server-app/pom.xml`, add after the `spring-boot-starter-security` dependency (around line 88):
|
||||
In `cameleer-server-app/pom.xml`, add after the `spring-boot-starter-security` dependency (around line 88):
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
@@ -41,7 +41,7 @@ In `cameleer3-server-app/pom.xml`, add after the `spring-boot-starter-security`
|
||||
|
||||
- [ ] **Step 2: Add OIDC properties to application.yml**
|
||||
|
||||
In `cameleer3-server-app/src/main/resources/application.yml`, add two new properties under the `security:` block (after line 52):
|
||||
In `cameleer-server-app/src/main/resources/application.yml`, add two new properties under the `security:` block (after line 52):
|
||||
|
||||
```yaml
|
||||
oidc-issuer-uri: ${CAMELEER_OIDC_ISSUER_URI:}
|
||||
@@ -50,7 +50,7 @@ In `cameleer3-server-app/src/main/resources/application.yml`, add two new proper
|
||||
|
||||
- [ ] **Step 3: Add fields to SecurityProperties.java**
|
||||
|
||||
In `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java`, add after the `jwtSecret` field (line 19):
|
||||
In `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityProperties.java`, add after the `jwtSecret` field (line 19):
|
||||
|
||||
```java
|
||||
private String oidcIssuerUri;
|
||||
@@ -64,13 +64,13 @@ public void setOidcAudience(String oidcAudience) { this.oidcAudience = oidcAudie
|
||||
|
||||
- [ ] **Step 4: Verify build compiles**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw compile -pl cameleer3-server-app -q`
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw compile -pl cameleer-server-app -q`
|
||||
Expected: BUILD SUCCESS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/pom.xml cameleer3-server-app/src/main/resources/application.yml cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java
|
||||
git add cameleer-server-app/pom.xml cameleer-server-app/src/main/resources/application.yml cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityProperties.java
|
||||
git commit -m "feat: add oauth2-resource-server dependency and OIDC config properties"
|
||||
```
|
||||
|
||||
@@ -79,14 +79,14 @@ git commit -m "feat: add oauth2-resource-server dependency and OIDC config prope
|
||||
### Task 2: Add conditional OIDC JwtDecoder bean
|
||||
|
||||
**Files:**
|
||||
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java`
|
||||
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/OidcJwtDecoderBeanTest.java`:
|
||||
Create `cameleer-server-app/src/test/java/com/cameleer/server/app/security/OidcJwtDecoderBeanTest.java`:
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.app.security;
|
||||
package com.cameleer.server.app.security;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
@@ -123,12 +123,12 @@ class OidcJwtDecoderBeanTest {
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw test -pl cameleer3-server-app -Dtest=OidcJwtDecoderBeanTest -q`
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw test -pl cameleer-server-app -Dtest=OidcJwtDecoderBeanTest -q`
|
||||
Expected: FAIL — method `oidcJwtDecoder` does not exist
|
||||
|
||||
- [ ] **Step 3: Add the oidcJwtDecoder method to SecurityBeanConfig**
|
||||
|
||||
In `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java`, add these imports at the top:
|
||||
In `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java`, add these imports at the top:
|
||||
|
||||
```java
|
||||
import com.nimbusds.jose.JWSAlgorithm;
|
||||
@@ -216,13 +216,13 @@ Update the test to match: the test calls `config.oidcJwtDecoder(properties)` dir
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw test -pl cameleer3-server-app -Dtest=OidcJwtDecoderBeanTest -q`
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw test -pl cameleer-server-app -Dtest=OidcJwtDecoderBeanTest -q`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/OidcJwtDecoderBeanTest.java
|
||||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java cameleer-server-app/src/test/java/com/cameleer/server/app/security/OidcJwtDecoderBeanTest.java
|
||||
git commit -m "feat: add conditional OIDC JwtDecoder factory for Logto token validation"
|
||||
```
|
||||
|
||||
@@ -231,18 +231,18 @@ git commit -m "feat: add conditional OIDC JwtDecoder factory for Logto token val
|
||||
### Task 3: Update JwtAuthenticationFilter with OIDC fallback
|
||||
|
||||
**Files:**
|
||||
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java`
|
||||
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtAuthenticationFilterOidcTest.java`:
|
||||
Create `cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtAuthenticationFilterOidcTest.java`:
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.app.security;
|
||||
package com.cameleer.server.app.security;
|
||||
|
||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||
import com.cameleer3.server.core.security.InvalidTokenException;
|
||||
import com.cameleer3.server.core.security.JwtService;
|
||||
import com.cameleer.server.core.agent.AgentRegistryService;
|
||||
import com.cameleer.server.core.security.InvalidTokenException;
|
||||
import com.cameleer.server.core.security.JwtService;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -369,19 +369,19 @@ class JwtAuthenticationFilterOidcTest {
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw test -pl cameleer3-server-app -Dtest=JwtAuthenticationFilterOidcTest -q`
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw test -pl cameleer-server-app -Dtest=JwtAuthenticationFilterOidcTest -q`
|
||||
Expected: FAIL — constructor doesn't accept 3 args
|
||||
|
||||
- [ ] **Step 3: Update JwtAuthenticationFilter**
|
||||
|
||||
Replace `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java` with:
|
||||
Replace `cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java` with:
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.app.security;
|
||||
package com.cameleer.server.app.security;
|
||||
|
||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||
import com.cameleer3.server.core.security.JwtService;
|
||||
import com.cameleer3.server.core.security.JwtService.JwtValidationResult;
|
||||
import com.cameleer.server.core.agent.AgentRegistryService;
|
||||
import com.cameleer.server.core.security.JwtService;
|
||||
import com.cameleer.server.core.security.JwtService.JwtValidationResult;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
@@ -508,13 +508,13 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw test -pl cameleer3-server-app -Dtest=JwtAuthenticationFilterOidcTest -q`
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw test -pl cameleer-server-app -Dtest=JwtAuthenticationFilterOidcTest -q`
|
||||
Expected: PASS (all 4 tests)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtAuthenticationFilterOidcTest.java
|
||||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtAuthenticationFilterOidcTest.java
|
||||
git commit -m "feat: add OIDC token fallback to JwtAuthenticationFilter"
|
||||
```
|
||||
|
||||
@@ -523,8 +523,8 @@ git commit -m "feat: add OIDC token fallback to JwtAuthenticationFilter"
|
||||
### Task 4: Wire OIDC decoder into SecurityConfig
|
||||
|
||||
**Files:**
|
||||
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java`
|
||||
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java`
|
||||
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java`
|
||||
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java`
|
||||
|
||||
- [ ] **Step 1: Add OIDC decoder bean creation to SecurityBeanConfig**
|
||||
|
||||
@@ -595,13 +595,13 @@ import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
|
||||
- [ ] **Step 3: Run existing tests**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw test -pl cameleer3-server-app -q`
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && ./mvnw test -pl cameleer-server-app -q`
|
||||
Expected: All existing tests PASS (no OIDC env vars set, decoder is null, filter behaves as before)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java
|
||||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java
|
||||
git commit -m "feat: wire optional OIDC JwtDecoder into security filter chain"
|
||||
```
|
||||
|
||||
@@ -1685,9 +1685,9 @@ In `docker-compose.yml`, remove these two labels from `cameleer-saas` (lines 122
|
||||
- traefik.http.services.forwardauth.loadbalancer.server.port=8080
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove ForwardAuth middleware from cameleer3-server**
|
||||
- [ ] **Step 2: Remove ForwardAuth middleware from cameleer-server**
|
||||
|
||||
In `docker-compose.yml`, remove the forward-auth middleware labels from `cameleer3-server` (lines 158-159):
|
||||
In `docker-compose.yml`, remove the forward-auth middleware labels from `cameleer-server` (lines 158-159):
|
||||
|
||||
```yaml
|
||||
- traefik.http.routers.observe.middlewares=forward-auth
|
||||
@@ -1719,7 +1719,7 @@ In `cameleer-saas` environment, remove:
|
||||
CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}
|
||||
```
|
||||
|
||||
In `cameleer3-server` environment, add:
|
||||
In `cameleer-server` environment, add:
|
||||
```yaml
|
||||
CAMELEER_OIDC_ISSUER_URI: ${LOGTO_ISSUER_URI:-http://logto:3001/oidc}
|
||||
CAMELEER_OIDC_AUDIENCE: ${CAMELEER_OIDC_AUDIENCE:-https://api.cameleer.local}
|
||||
|
||||
@@ -8,41 +8,41 @@
|
||||
|
||||
**Tech Stack:** Java 17, Spring Boot 3.4.3, PostgreSQL 16, Flyway, JUnit 5, Testcontainers, AssertJ
|
||||
|
||||
**Repo:** `C:\Users\Hendrik\Documents\projects\cameleer3-server`
|
||||
**Repo:** `C:\Users\Hendrik\Documents\projects\cameleer-server`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
### New Files
|
||||
- `cameleer3-server-app/src/main/resources/db/migration/V2__claim_mapping.sql`
|
||||
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRule.java`
|
||||
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRepository.java`
|
||||
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingService.java`
|
||||
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/AssignmentOrigin.java`
|
||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresClaimMappingRepository.java`
|
||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ClaimMappingAdminController.java`
|
||||
- `cameleer3-server-app/src/test/java/com/cameleer3/server/core/rbac/ClaimMappingServiceTest.java`
|
||||
- `cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ClaimMappingAdminControllerIT.java`
|
||||
- `cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/OidcOnlyModeIT.java`
|
||||
- `cameleer-server-app/src/main/resources/db/migration/V2__claim_mapping.sql`
|
||||
- `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/ClaimMappingRule.java`
|
||||
- `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/ClaimMappingRepository.java`
|
||||
- `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/ClaimMappingService.java`
|
||||
- `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/AssignmentOrigin.java`
|
||||
- `cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresClaimMappingRepository.java`
|
||||
- `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/ClaimMappingAdminController.java`
|
||||
- `cameleer-server-app/src/test/java/com/cameleer/server/core/rbac/ClaimMappingServiceTest.java`
|
||||
- `cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ClaimMappingAdminControllerIT.java`
|
||||
- `cameleer-server-app/src/test/java/com/cameleer/server/app/security/OidcOnlyModeIT.java`
|
||||
|
||||
### Modified Files
|
||||
- `cameleer3-server-app/src/main/resources/db/migration/V1__init.sql` — no changes (immutable)
|
||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/rbac/RbacServiceImpl.java` — add origin-aware query methods
|
||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresUserRepository.java` — add origin-aware queries
|
||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java` — replace syncOidcRoles with claim mapping
|
||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java` — disable internal token path in OIDC-only mode
|
||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java` — conditional endpoint registration
|
||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java` — disable in OIDC-only mode
|
||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryBeanConfig.java` — wire ClaimMappingService
|
||||
- `cameleer3-server-app/src/main/resources/application.yml` — no new properties needed (OIDC config already exists)
|
||||
- `cameleer-server-app/src/main/resources/db/migration/V1__init.sql` — no changes (immutable)
|
||||
- `cameleer-server-app/src/main/java/com/cameleer/server/app/rbac/RbacServiceImpl.java` — add origin-aware query methods
|
||||
- `cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresUserRepository.java` — add origin-aware queries
|
||||
- `cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcAuthController.java` — replace syncOidcRoles with claim mapping
|
||||
- `cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java` — disable internal token path in OIDC-only mode
|
||||
- `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java` — conditional endpoint registration
|
||||
- `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/UserAdminController.java` — disable in OIDC-only mode
|
||||
- `cameleer-server-app/src/main/java/com/cameleer/server/app/config/AgentRegistryBeanConfig.java` — wire ClaimMappingService
|
||||
- `cameleer-server-app/src/main/resources/application.yml` — no new properties needed (OIDC config already exists)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Database Migration — Add Origin Tracking and Claim Mapping Rules
|
||||
|
||||
**Files:**
|
||||
- Create: `cameleer3-server-app/src/main/resources/db/migration/V2__claim_mapping.sql`
|
||||
- Create: `cameleer-server-app/src/main/resources/db/migration/V2__claim_mapping.sql`
|
||||
|
||||
- [ ] **Step 1: Write the migration**
|
||||
|
||||
@@ -90,14 +90,14 @@ CREATE INDEX idx_user_groups_origin ON user_groups(user_id, origin);
|
||||
|
||||
- [ ] **Step 2: Run migration to verify**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn flyway:migrate -pl cameleer3-server-app -Dflyway.url=jdbc:postgresql://localhost:5432/cameleer3 -Dflyway.user=cameleer -Dflyway.password=cameleer_dev`
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn flyway:migrate -pl cameleer-server-app -Dflyway.url=jdbc:postgresql://localhost:5432/cameleer -Dflyway.user=cameleer -Dflyway.password=cameleer_dev`
|
||||
|
||||
If no local PostgreSQL, verify syntax by running the existing test suite which uses Testcontainers.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/main/resources/db/migration/V2__claim_mapping.sql
|
||||
git add cameleer-server-app/src/main/resources/db/migration/V2__claim_mapping.sql
|
||||
git commit -m "feat: add claim mapping rules table and origin tracking to RBAC assignments"
|
||||
```
|
||||
|
||||
@@ -106,14 +106,14 @@ git commit -m "feat: add claim mapping rules table and origin tracking to RBAC a
|
||||
### Task 2: Core Domain — ClaimMappingRule, AssignmentOrigin, Repository Interface
|
||||
|
||||
**Files:**
|
||||
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/AssignmentOrigin.java`
|
||||
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRule.java`
|
||||
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRepository.java`
|
||||
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/AssignmentOrigin.java`
|
||||
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/ClaimMappingRule.java`
|
||||
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/ClaimMappingRepository.java`
|
||||
|
||||
- [ ] **Step 1: Create AssignmentOrigin enum**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.core.rbac;
|
||||
package com.cameleer.server.core.rbac;
|
||||
|
||||
public enum AssignmentOrigin {
|
||||
direct, managed
|
||||
@@ -123,7 +123,7 @@ public enum AssignmentOrigin {
|
||||
- [ ] **Step 2: Create ClaimMappingRule record**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.core.rbac;
|
||||
package com.cameleer.server.core.rbac;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
@@ -146,7 +146,7 @@ public record ClaimMappingRule(
|
||||
- [ ] **Step 3: Create ClaimMappingRepository interface**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.core.rbac;
|
||||
package com.cameleer.server.core.rbac;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -164,9 +164,9 @@ public interface ClaimMappingRepository {
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/AssignmentOrigin.java
|
||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRule.java
|
||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRepository.java
|
||||
git add cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/AssignmentOrigin.java
|
||||
git add cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/ClaimMappingRule.java
|
||||
git add cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/ClaimMappingRepository.java
|
||||
git commit -m "feat: add ClaimMappingRule domain model and repository interface"
|
||||
```
|
||||
|
||||
@@ -175,13 +175,13 @@ git commit -m "feat: add ClaimMappingRule domain model and repository interface"
|
||||
### Task 3: Core Domain — ClaimMappingService
|
||||
|
||||
**Files:**
|
||||
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingService.java`
|
||||
- Create: `cameleer3-server-app/src/test/java/com/cameleer3/server/core/rbac/ClaimMappingServiceTest.java`
|
||||
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/ClaimMappingService.java`
|
||||
- Create: `cameleer-server-app/src/test/java/com/cameleer/server/core/rbac/ClaimMappingServiceTest.java`
|
||||
|
||||
- [ ] **Step 1: Write tests for ClaimMappingService**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.core.rbac;
|
||||
package com.cameleer.server.core.rbac;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -300,13 +300,13 @@ class ClaimMappingServiceTest {
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=ClaimMappingServiceTest -Dsurefire.failIfNoSpecifiedTests=false`
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app -Dtest=ClaimMappingServiceTest -Dsurefire.failIfNoSpecifiedTests=false`
|
||||
Expected: Compilation error — ClaimMappingService does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement ClaimMappingService**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.core.rbac;
|
||||
package com.cameleer.server.core.rbac;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -377,14 +377,14 @@ public class ClaimMappingService {
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=ClaimMappingServiceTest`
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app -Dtest=ClaimMappingServiceTest`
|
||||
Expected: All 7 tests PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingService.java
|
||||
git add cameleer3-server-app/src/test/java/com/cameleer3/server/core/rbac/ClaimMappingServiceTest.java
|
||||
git add cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/ClaimMappingService.java
|
||||
git add cameleer-server-app/src/test/java/com/cameleer/server/core/rbac/ClaimMappingServiceTest.java
|
||||
git commit -m "feat: implement ClaimMappingService with equals/contains/regex matching"
|
||||
```
|
||||
|
||||
@@ -393,15 +393,15 @@ git commit -m "feat: implement ClaimMappingService with equals/contains/regex ma
|
||||
### Task 4: PostgreSQL Repository — ClaimMappingRepository Implementation
|
||||
|
||||
**Files:**
|
||||
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresClaimMappingRepository.java`
|
||||
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresClaimMappingRepository.java`
|
||||
|
||||
- [ ] **Step 1: Implement PostgresClaimMappingRepository**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.app.storage;
|
||||
package com.cameleer.server.app.storage;
|
||||
|
||||
import com.cameleer3.server.core.rbac.ClaimMappingRepository;
|
||||
import com.cameleer3.server.core.rbac.ClaimMappingRule;
|
||||
import com.cameleer.server.core.rbac.ClaimMappingRepository;
|
||||
import com.cameleer.server.core.rbac.ClaimMappingRule;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.util.List;
|
||||
@@ -479,7 +479,7 @@ public class PostgresClaimMappingRepository implements ClaimMappingRepository {
|
||||
|
||||
- [ ] **Step 2: Wire the bean in AgentRegistryBeanConfig (or a new RbacBeanConfig)**
|
||||
|
||||
Add to `cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryBeanConfig.java` (or create a new `RbacBeanConfig.java`):
|
||||
Add to `cameleer-server-app/src/main/java/com/cameleer/server/app/config/AgentRegistryBeanConfig.java` (or create a new `RbacBeanConfig.java`):
|
||||
|
||||
```java
|
||||
@Bean
|
||||
@@ -496,8 +496,8 @@ public ClaimMappingService claimMappingService() {
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresClaimMappingRepository.java
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryBeanConfig.java
|
||||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresClaimMappingRepository.java
|
||||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/config/AgentRegistryBeanConfig.java
|
||||
git commit -m "feat: implement PostgresClaimMappingRepository and wire beans"
|
||||
```
|
||||
|
||||
@@ -506,11 +506,11 @@ git commit -m "feat: implement PostgresClaimMappingRepository and wire beans"
|
||||
### Task 5: Modify RbacServiceImpl — Origin-Aware Assignments
|
||||
|
||||
**Files:**
|
||||
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/rbac/RbacServiceImpl.java`
|
||||
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/rbac/RbacServiceImpl.java`
|
||||
|
||||
- [ ] **Step 1: Add managed assignment methods to RbacService interface**
|
||||
|
||||
In `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RbacService.java`, add:
|
||||
In `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/RbacService.java`, add:
|
||||
|
||||
```java
|
||||
void clearManagedAssignments(String userId);
|
||||
@@ -592,14 +592,14 @@ public List<RoleSummary> getDirectRolesForUser(String userId) {
|
||||
|
||||
- [ ] **Step 5: Run existing tests**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app`
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app`
|
||||
Expected: All existing tests still pass (migration adds columns with defaults).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RbacService.java
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/rbac/RbacServiceImpl.java
|
||||
git add cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/RbacService.java
|
||||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/rbac/RbacServiceImpl.java
|
||||
git commit -m "feat: add origin-aware managed/direct assignment methods to RbacService"
|
||||
```
|
||||
|
||||
@@ -608,7 +608,7 @@ git commit -m "feat: add origin-aware managed/direct assignment methods to RbacS
|
||||
### Task 6: Modify OidcAuthController — Replace syncOidcRoles with Claim Mapping
|
||||
|
||||
**Files:**
|
||||
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java`
|
||||
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcAuthController.java`
|
||||
|
||||
- [ ] **Step 1: Inject ClaimMappingService and ClaimMappingRepository**
|
||||
|
||||
@@ -676,13 +676,13 @@ Note: `extractAllClaims` needs to be added to `OidcTokenExchanger` — it return
|
||||
|
||||
- [ ] **Step 4: Run existing tests**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app`
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app`
|
||||
Expected: PASS (OIDC tests may need adjustment if they test syncOidcRoles directly).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java
|
||||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcAuthController.java
|
||||
git commit -m "feat: replace syncOidcRoles with claim mapping evaluation on OIDC login"
|
||||
```
|
||||
|
||||
@@ -691,8 +691,8 @@ git commit -m "feat: replace syncOidcRoles with claim mapping evaluation on OIDC
|
||||
### Task 7: OIDC-Only Mode — Disable Local Auth When OIDC Configured
|
||||
|
||||
**Files:**
|
||||
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java`
|
||||
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java`
|
||||
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java`
|
||||
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java`
|
||||
|
||||
- [ ] **Step 1: Add isOidcEnabled() helper to SecurityConfig**
|
||||
|
||||
@@ -760,15 +760,15 @@ public ResponseEntity<?> resetPassword(@PathVariable String userId, @RequestBody
|
||||
|
||||
- [ ] **Step 5: Run full test suite**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app`
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java
|
||||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java
|
||||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java
|
||||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/controller/UserAdminController.java
|
||||
git commit -m "feat: disable local auth when OIDC is configured (resource server mode)"
|
||||
```
|
||||
|
||||
@@ -777,15 +777,15 @@ git commit -m "feat: disable local auth when OIDC is configured (resource server
|
||||
### Task 8: Claim Mapping Admin Controller
|
||||
|
||||
**Files:**
|
||||
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ClaimMappingAdminController.java`
|
||||
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/ClaimMappingAdminController.java`
|
||||
|
||||
- [ ] **Step 1: Implement the controller**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.app.controller;
|
||||
package com.cameleer.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.core.rbac.ClaimMappingRepository;
|
||||
import com.cameleer3.server.core.rbac.ClaimMappingRule;
|
||||
import com.cameleer.server.core.rbac.ClaimMappingRepository;
|
||||
import com.cameleer.server.core.rbac.ClaimMappingRule;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -867,13 +867,13 @@ In `SecurityConfig.filterChain()`, the `/api/v1/admin/**` path already requires
|
||||
|
||||
- [ ] **Step 3: Run full test suite**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app`
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ClaimMappingAdminController.java
|
||||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/controller/ClaimMappingAdminController.java
|
||||
git commit -m "feat: add ClaimMappingAdminController for CRUD on mapping rules"
|
||||
```
|
||||
|
||||
@@ -882,14 +882,14 @@ git commit -m "feat: add ClaimMappingAdminController for CRUD on mapping rules"
|
||||
### Task 9: Integration Test — Claim Mapping End-to-End
|
||||
|
||||
**Files:**
|
||||
- Create: `cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ClaimMappingAdminControllerIT.java`
|
||||
- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ClaimMappingAdminControllerIT.java`
|
||||
|
||||
- [ ] **Step 1: Write integration test**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.app.controller;
|
||||
package com.cameleer.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.app.AbstractPostgresIT;
|
||||
import com.cameleer.server.app.AbstractPostgresIT;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -954,13 +954,13 @@ class ClaimMappingAdminControllerIT extends AbstractPostgresIT {
|
||||
|
||||
- [ ] **Step 2: Run integration tests**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=ClaimMappingAdminControllerIT`
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app -Dtest=ClaimMappingAdminControllerIT`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ClaimMappingAdminControllerIT.java
|
||||
git add cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ClaimMappingAdminControllerIT.java
|
||||
git commit -m "test: add integration tests for claim mapping admin API"
|
||||
```
|
||||
|
||||
@@ -970,12 +970,12 @@ git commit -m "test: add integration tests for claim mapping admin API"
|
||||
|
||||
- [ ] **Step 1: Run all tests**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify`
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn clean verify`
|
||||
Expected: All tests PASS. Build succeeds.
|
||||
|
||||
- [ ] **Step 2: Verify migration applies cleanly on fresh database**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=AbstractPostgresIT`
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app -Dtest=AbstractPostgresIT`
|
||||
Expected: Testcontainers starts fresh PostgreSQL, Flyway applies V1 + V2, context loads.
|
||||
|
||||
- [ ] **Step 3: Commit any remaining fixes**
|
||||
|
||||
@@ -8,37 +8,37 @@
|
||||
|
||||
**Tech Stack:** Java 17, Spring Boot 3.4.3, Ed25519 (JDK built-in), Nimbus JOSE JWT, JUnit 5, AssertJ
|
||||
|
||||
**Repo:** `C:\Users\Hendrik\Documents\projects\cameleer3-server`
|
||||
**Repo:** `C:\Users\Hendrik\Documents\projects\cameleer-server`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
### New Files
|
||||
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseInfo.java`
|
||||
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseValidator.java`
|
||||
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseGate.java`
|
||||
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/Feature.java`
|
||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/LicenseBeanConfig.java`
|
||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/LicenseAdminController.java`
|
||||
- `cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseValidatorTest.java`
|
||||
- `cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseGateTest.java`
|
||||
- `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java`
|
||||
- `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java`
|
||||
- `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseGate.java`
|
||||
- `cameleer-server-core/src/main/java/com/cameleer/server/core/license/Feature.java`
|
||||
- `cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java`
|
||||
- `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseAdminController.java`
|
||||
- `cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseValidatorTest.java`
|
||||
- `cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseGateTest.java`
|
||||
|
||||
### Modified Files
|
||||
- `cameleer3-server-app/src/main/resources/application.yml` — add license config properties
|
||||
- `cameleer-server-app/src/main/resources/application.yml` — add license config properties
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Core Domain — LicenseInfo, Feature Enum
|
||||
|
||||
**Files:**
|
||||
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/Feature.java`
|
||||
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseInfo.java`
|
||||
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/Feature.java`
|
||||
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java`
|
||||
|
||||
- [ ] **Step 1: Create Feature enum**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.core.license;
|
||||
package com.cameleer.server.core.license;
|
||||
|
||||
public enum Feature {
|
||||
topology,
|
||||
@@ -52,7 +52,7 @@ public enum Feature {
|
||||
- [ ] **Step 2: Create LicenseInfo record**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.core.license;
|
||||
package com.cameleer.server.core.license;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
@@ -87,8 +87,8 @@ public record LicenseInfo(
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/Feature.java
|
||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseInfo.java
|
||||
git add cameleer-server-core/src/main/java/com/cameleer/server/core/license/Feature.java
|
||||
git add cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java
|
||||
git commit -m "feat: add LicenseInfo and Feature domain model"
|
||||
```
|
||||
|
||||
@@ -97,13 +97,13 @@ git commit -m "feat: add LicenseInfo and Feature domain model"
|
||||
### Task 2: LicenseValidator — Ed25519 JWT Verification
|
||||
|
||||
**Files:**
|
||||
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseValidator.java`
|
||||
- Create: `cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseValidatorTest.java`
|
||||
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java`
|
||||
- Create: `cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseValidatorTest.java`
|
||||
|
||||
- [ ] **Step 1: Write tests**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.core.license;
|
||||
package com.cameleer.server.core.license;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -194,13 +194,13 @@ class LicenseValidatorTest {
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=LicenseValidatorTest -Dsurefire.failIfNoSpecifiedTests=false`
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app -Dtest=LicenseValidatorTest -Dsurefire.failIfNoSpecifiedTests=false`
|
||||
Expected: Compilation error — LicenseValidator does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement LicenseValidator**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.core.license;
|
||||
package com.cameleer.server.core.license;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -298,14 +298,14 @@ public class LicenseValidator {
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=LicenseValidatorTest`
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app -Dtest=LicenseValidatorTest`
|
||||
Expected: All 3 tests PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseValidator.java
|
||||
git add cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseValidatorTest.java
|
||||
git add cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java
|
||||
git add cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseValidatorTest.java
|
||||
git commit -m "feat: implement LicenseValidator with Ed25519 signature verification"
|
||||
```
|
||||
|
||||
@@ -314,13 +314,13 @@ git commit -m "feat: implement LicenseValidator with Ed25519 signature verificat
|
||||
### Task 3: LicenseGate — Feature Check Service
|
||||
|
||||
**Files:**
|
||||
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseGate.java`
|
||||
- Create: `cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseGateTest.java`
|
||||
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseGate.java`
|
||||
- Create: `cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseGateTest.java`
|
||||
|
||||
- [ ] **Step 1: Write tests**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.core.license;
|
||||
package com.cameleer.server.core.license;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -366,7 +366,7 @@ class LicenseGateTest {
|
||||
- [ ] **Step 2: Implement LicenseGate**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.core.license;
|
||||
package com.cameleer.server.core.license;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -405,14 +405,14 @@ public class LicenseGate {
|
||||
|
||||
- [ ] **Step 3: Run tests**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=LicenseGateTest`
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app -Dtest=LicenseGateTest`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseGate.java
|
||||
git add cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseGateTest.java
|
||||
git add cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseGate.java
|
||||
git add cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseGateTest.java
|
||||
git commit -m "feat: implement LicenseGate for feature checking"
|
||||
```
|
||||
|
||||
@@ -421,8 +421,8 @@ git commit -m "feat: implement LicenseGate for feature checking"
|
||||
### Task 4: License Loading — Bean Config and Startup
|
||||
|
||||
**Files:**
|
||||
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/LicenseBeanConfig.java`
|
||||
- Modify: `cameleer3-server-app/src/main/resources/application.yml`
|
||||
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java`
|
||||
- Modify: `cameleer-server-app/src/main/resources/application.yml`
|
||||
|
||||
- [ ] **Step 1: Add license config properties to application.yml**
|
||||
|
||||
@@ -436,11 +436,11 @@ license:
|
||||
- [ ] **Step 2: Implement LicenseBeanConfig**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.app.config;
|
||||
package com.cameleer.server.app.config;
|
||||
|
||||
import com.cameleer3.server.core.license.LicenseGate;
|
||||
import com.cameleer3.server.core.license.LicenseInfo;
|
||||
import com.cameleer3.server.core.license.LicenseValidator;
|
||||
import com.cameleer.server.core.license.LicenseGate;
|
||||
import com.cameleer.server.core.license.LicenseInfo;
|
||||
import com.cameleer.server.core.license.LicenseValidator;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -509,8 +509,8 @@ public class LicenseBeanConfig {
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/LicenseBeanConfig.java
|
||||
git add cameleer3-server-app/src/main/resources/application.yml
|
||||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java
|
||||
git add cameleer-server-app/src/main/resources/application.yml
|
||||
git commit -m "feat: add license loading at startup from env var or file"
|
||||
```
|
||||
|
||||
@@ -519,16 +519,16 @@ git commit -m "feat: add license loading at startup from env var or file"
|
||||
### Task 5: License Admin API — Runtime License Update
|
||||
|
||||
**Files:**
|
||||
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/LicenseAdminController.java`
|
||||
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseAdminController.java`
|
||||
|
||||
- [ ] **Step 1: Implement controller**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.app.controller;
|
||||
package com.cameleer.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.core.license.LicenseGate;
|
||||
import com.cameleer3.server.core.license.LicenseInfo;
|
||||
import com.cameleer3.server.core.license.LicenseValidator;
|
||||
import com.cameleer.server.core.license.LicenseGate;
|
||||
import com.cameleer.server.core.license.LicenseInfo;
|
||||
import com.cameleer.server.core.license.LicenseValidator;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -581,13 +581,13 @@ public class LicenseAdminController {
|
||||
|
||||
- [ ] **Step 2: Run full test suite**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify`
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn clean verify`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/LicenseAdminController.java
|
||||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseAdminController.java
|
||||
git commit -m "feat: add license admin API for runtime license updates"
|
||||
```
|
||||
|
||||
@@ -611,5 +611,5 @@ public ResponseEntity<?> listDebugSessions() {
|
||||
|
||||
- [ ] **Step 2: Final verification**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify`
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn clean verify`
|
||||
Expected: All tests PASS.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Plan 3: Runtime Management in the Server
|
||||
|
||||
> **Status: COMPLETED** — Verified 2026-04-09. All runtime management fully ported to cameleer3-server with enhancements beyond the original plan.
|
||||
> **Status: COMPLETED** — Verified 2026-04-09. All runtime management fully ported to cameleer-server with enhancements beyond the original plan.
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
**Tech Stack:** Java 17, Spring Boot 3.4.3, docker-java (zerodep transport), PostgreSQL 16, Flyway, JUnit 5, Testcontainers
|
||||
|
||||
**Repo:** `C:\Users\Hendrik\Documents\projects\cameleer3-server`
|
||||
**Repo:** `C:\Users\Hendrik\Documents\projects\cameleer-server`
|
||||
|
||||
**Source reference:** Code ported from `C:\Users\Hendrik\Documents\projects\cameleer-saas` (environment, app, deployment, runtime packages)
|
||||
|
||||
@@ -18,10 +18,10 @@
|
||||
|
||||
## File Map
|
||||
|
||||
### New Files — Core Module (`cameleer3-server-core`)
|
||||
### New Files — Core Module (`cameleer-server-core`)
|
||||
|
||||
```
|
||||
src/main/java/com/cameleer3/server/core/runtime/
|
||||
src/main/java/com/cameleer/server/core/runtime/
|
||||
├── Environment.java Record: id, slug, displayName, status, createdAt
|
||||
├── EnvironmentStatus.java Enum: ACTIVE, SUSPENDED
|
||||
├── EnvironmentRepository.java Interface: CRUD + findBySlug
|
||||
@@ -42,10 +42,10 @@ src/main/java/com/cameleer3/server/core/runtime/
|
||||
└── RoutingMode.java Enum: path, subdomain
|
||||
```
|
||||
|
||||
### New Files — App Module (`cameleer3-server-app`)
|
||||
### New Files — App Module (`cameleer-server-app`)
|
||||
|
||||
```
|
||||
src/main/java/com/cameleer3/server/app/runtime/
|
||||
src/main/java/com/cameleer/server/app/runtime/
|
||||
├── DockerRuntimeOrchestrator.java Docker implementation using docker-java
|
||||
├── DisabledRuntimeOrchestrator.java No-op implementation (observability-only mode)
|
||||
├── RuntimeOrchestratorAutoConfig.java @Configuration: auto-detects Docker vs K8s vs disabled
|
||||
@@ -53,13 +53,13 @@ src/main/java/com/cameleer3/server/app/runtime/
|
||||
├── JarStorageService.java File-system JAR storage with versioning
|
||||
└── ContainerLogCollector.java Collects Docker container stdout/stderr
|
||||
|
||||
src/main/java/com/cameleer3/server/app/storage/
|
||||
src/main/java/com/cameleer/server/app/storage/
|
||||
├── PostgresEnvironmentRepository.java
|
||||
├── PostgresAppRepository.java
|
||||
├── PostgresAppVersionRepository.java
|
||||
└── PostgresDeploymentRepository.java
|
||||
|
||||
src/main/java/com/cameleer3/server/app/controller/
|
||||
src/main/java/com/cameleer/server/app/controller/
|
||||
├── EnvironmentAdminController.java CRUD endpoints under /api/v1/admin/environments
|
||||
├── AppController.java App + version CRUD + JAR upload
|
||||
└── DeploymentController.java Deploy, stop, restart, promote, logs
|
||||
@@ -70,7 +70,7 @@ src/main/resources/db/migration/
|
||||
|
||||
### Modified Files
|
||||
- `pom.xml` (parent) — add docker-java dependency
|
||||
- `cameleer3-server-app/pom.xml` — add docker-java dependency
|
||||
- `cameleer-server-app/pom.xml` — add docker-java dependency
|
||||
- `application.yml` — add runtime config properties
|
||||
|
||||
---
|
||||
@@ -78,7 +78,7 @@ src/main/resources/db/migration/
|
||||
### Task 1: Add docker-java Dependency
|
||||
|
||||
**Files:**
|
||||
- Modify: `cameleer3-server-app/pom.xml`
|
||||
- Modify: `cameleer-server-app/pom.xml`
|
||||
|
||||
- [x] **Step 1: Add docker-java dependency**
|
||||
|
||||
@@ -97,13 +97,13 @@ src/main/resources/db/migration/
|
||||
|
||||
- [x] **Step 2: Verify build**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn compile -pl cameleer3-server-app`
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn compile -pl cameleer-server-app`
|
||||
Expected: BUILD SUCCESS.
|
||||
|
||||
- [x] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/pom.xml
|
||||
git add cameleer-server-app/pom.xml
|
||||
git commit -m "chore: add docker-java dependency for runtime orchestration"
|
||||
```
|
||||
|
||||
@@ -112,7 +112,7 @@ git commit -m "chore: add docker-java dependency for runtime orchestration"
|
||||
### Task 2: Database Migration — Runtime Management Tables
|
||||
|
||||
**Files:**
|
||||
- Create: `cameleer3-server-app/src/main/resources/db/migration/V3__runtime_management.sql`
|
||||
- Create: `cameleer-server-app/src/main/resources/db/migration/V3__runtime_management.sql`
|
||||
|
||||
- [x] **Step 1: Write migration**
|
||||
|
||||
@@ -176,7 +176,7 @@ INSERT INTO environments (slug, display_name) VALUES ('default', 'Default');
|
||||
- [x] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/main/resources/db/migration/V3__runtime_management.sql
|
||||
git add cameleer-server-app/src/main/resources/db/migration/V3__runtime_management.sql
|
||||
git commit -m "feat: add runtime management database schema (environments, apps, versions, deployments)"
|
||||
```
|
||||
|
||||
@@ -185,36 +185,36 @@ git commit -m "feat: add runtime management database schema (environments, apps,
|
||||
### Task 3: Core Domain — Environment, App, AppVersion, Deployment Records
|
||||
|
||||
**Files:**
|
||||
- Create all records in `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/`
|
||||
- Create all records in `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/`
|
||||
|
||||
- [x] **Step 1: Create all domain records**
|
||||
|
||||
```java
|
||||
// Environment.java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
package com.cameleer.server.core.runtime;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
public record Environment(UUID id, String slug, String displayName, EnvironmentStatus status, Instant createdAt) {}
|
||||
|
||||
// EnvironmentStatus.java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
package com.cameleer.server.core.runtime;
|
||||
public enum EnvironmentStatus { ACTIVE, SUSPENDED }
|
||||
|
||||
// App.java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
package com.cameleer.server.core.runtime;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
public record App(UUID id, UUID environmentId, String slug, String displayName, Instant createdAt) {}
|
||||
|
||||
// AppVersion.java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
package com.cameleer.server.core.runtime;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
public record AppVersion(UUID id, UUID appId, int version, String jarPath, String jarChecksum,
|
||||
String jarFilename, Long jarSizeBytes, Instant uploadedAt) {}
|
||||
|
||||
// Deployment.java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
package com.cameleer.server.core.runtime;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
public record Deployment(UUID id, UUID appId, UUID appVersionId, UUID environmentId,
|
||||
@@ -227,18 +227,18 @@ public record Deployment(UUID id, UUID appId, UUID appVersionId, UUID environmen
|
||||
}
|
||||
|
||||
// DeploymentStatus.java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
package com.cameleer.server.core.runtime;
|
||||
public enum DeploymentStatus { STARTING, RUNNING, FAILED, STOPPED }
|
||||
|
||||
// RoutingMode.java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
package com.cameleer.server.core.runtime;
|
||||
public enum RoutingMode { path, subdomain }
|
||||
```
|
||||
|
||||
- [x] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/
|
||||
git add cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/
|
||||
git commit -m "feat: add runtime management domain records"
|
||||
```
|
||||
|
||||
@@ -253,7 +253,7 @@ git commit -m "feat: add runtime management domain records"
|
||||
|
||||
```java
|
||||
// EnvironmentRepository.java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
package com.cameleer.server.core.runtime;
|
||||
import java.util.*;
|
||||
public interface EnvironmentRepository {
|
||||
List<Environment> findAll();
|
||||
@@ -266,7 +266,7 @@ public interface EnvironmentRepository {
|
||||
}
|
||||
|
||||
// AppRepository.java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
package com.cameleer.server.core.runtime;
|
||||
import java.util.*;
|
||||
public interface AppRepository {
|
||||
List<App> findByEnvironmentId(UUID environmentId);
|
||||
@@ -277,7 +277,7 @@ public interface AppRepository {
|
||||
}
|
||||
|
||||
// AppVersionRepository.java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
package com.cameleer.server.core.runtime;
|
||||
import java.util.*;
|
||||
public interface AppVersionRepository {
|
||||
List<AppVersion> findByAppId(UUID appId);
|
||||
@@ -287,7 +287,7 @@ public interface AppVersionRepository {
|
||||
}
|
||||
|
||||
// DeploymentRepository.java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
package com.cameleer.server.core.runtime;
|
||||
import java.util.*;
|
||||
public interface DeploymentRepository {
|
||||
List<Deployment> findByAppId(UUID appId);
|
||||
@@ -305,7 +305,7 @@ public interface DeploymentRepository {
|
||||
|
||||
```java
|
||||
// RuntimeOrchestrator.java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
package com.cameleer.server.core.runtime;
|
||||
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@@ -319,7 +319,7 @@ public interface RuntimeOrchestrator {
|
||||
}
|
||||
|
||||
// ContainerRequest.java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
package com.cameleer.server.core.runtime;
|
||||
import java.util.Map;
|
||||
public record ContainerRequest(
|
||||
String containerName,
|
||||
@@ -334,7 +334,7 @@ public record ContainerRequest(
|
||||
) {}
|
||||
|
||||
// ContainerStatus.java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
package com.cameleer.server.core.runtime;
|
||||
public record ContainerStatus(String state, boolean running, int exitCode, String error) {
|
||||
public static ContainerStatus notFound() {
|
||||
return new ContainerStatus("not_found", false, -1, "Container not found");
|
||||
@@ -345,7 +345,7 @@ public record ContainerStatus(String state, boolean running, int exitCode, Strin
|
||||
- [x] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/
|
||||
git add cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/
|
||||
git commit -m "feat: add runtime repository interfaces and RuntimeOrchestrator"
|
||||
```
|
||||
|
||||
@@ -359,7 +359,7 @@ git commit -m "feat: add runtime repository interfaces and RuntimeOrchestrator"
|
||||
- [x] **Step 1: Create EnvironmentService**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
package com.cameleer.server.core.runtime;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
@@ -395,7 +395,7 @@ public class EnvironmentService {
|
||||
- [x] **Step 2: Create AppService**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
package com.cameleer.server.core.runtime;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -478,7 +478,7 @@ public class AppService {
|
||||
- [x] **Step 3: Create DeploymentService**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
package com.cameleer.server.core.runtime;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -536,7 +536,7 @@ public class DeploymentService {
|
||||
- [x] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/
|
||||
git add cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/
|
||||
git commit -m "feat: add EnvironmentService, AppService, DeploymentService"
|
||||
```
|
||||
|
||||
@@ -598,14 +598,14 @@ public class RuntimeBeanConfig {
|
||||
|
||||
- [x] **Step 3: Run tests**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app`
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app`
|
||||
Expected: PASS (Flyway applies V3 migration, context loads).
|
||||
|
||||
- [x] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/Postgres*Repository.java
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/RuntimeBeanConfig.java
|
||||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/storage/Postgres*Repository.java
|
||||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java
|
||||
git commit -m "feat: implement PostgreSQL repositories for runtime management"
|
||||
```
|
||||
|
||||
@@ -614,16 +614,16 @@ git commit -m "feat: implement PostgreSQL repositories for runtime management"
|
||||
### Task 7: Docker Runtime Orchestrator
|
||||
|
||||
**Files:**
|
||||
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DockerRuntimeOrchestrator.java`
|
||||
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DisabledRuntimeOrchestrator.java`
|
||||
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/RuntimeOrchestratorAutoConfig.java`
|
||||
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DockerRuntimeOrchestrator.java`
|
||||
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DisabledRuntimeOrchestrator.java`
|
||||
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/RuntimeOrchestratorAutoConfig.java`
|
||||
|
||||
- [x] **Step 1: Implement DisabledRuntimeOrchestrator**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.app.runtime;
|
||||
package com.cameleer.server.app.runtime;
|
||||
|
||||
import com.cameleer3.server.core.runtime.*;
|
||||
import com.cameleer.server.core.runtime.*;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class DisabledRuntimeOrchestrator implements RuntimeOrchestrator {
|
||||
@@ -685,9 +685,9 @@ public String startContainer(ContainerRequest request) {
|
||||
- [x] **Step 3: Implement RuntimeOrchestratorAutoConfig**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.app.runtime;
|
||||
package com.cameleer.server.app.runtime;
|
||||
|
||||
import com.cameleer3.server.core.runtime.RuntimeOrchestrator;
|
||||
import com.cameleer.server.core.runtime.RuntimeOrchestrator;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
@@ -718,7 +718,7 @@ public class RuntimeOrchestratorAutoConfig {
|
||||
- [x] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/
|
||||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/
|
||||
git commit -m "feat: implement DockerRuntimeOrchestrator with volume-mount JAR deployment"
|
||||
```
|
||||
|
||||
@@ -727,14 +727,14 @@ git commit -m "feat: implement DockerRuntimeOrchestrator with volume-mount JAR d
|
||||
### Task 8: DeploymentExecutor — Async Deployment Pipeline
|
||||
|
||||
**Files:**
|
||||
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java`
|
||||
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java`
|
||||
|
||||
- [x] **Step 1: Implement async deployment pipeline**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.app.runtime;
|
||||
package com.cameleer.server.app.runtime;
|
||||
|
||||
import com.cameleer3.server.core.runtime.*;
|
||||
import com.cameleer.server.core.runtime.*;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
@@ -841,7 +841,7 @@ public TaskExecutor deploymentTaskExecutor() {
|
||||
- [x] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java
|
||||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java
|
||||
git commit -m "feat: implement async DeploymentExecutor pipeline"
|
||||
```
|
||||
|
||||
@@ -907,9 +907,9 @@ Add to `SecurityConfig.filterChain()`:
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/EnvironmentAdminController.java
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AppController.java
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DeploymentController.java
|
||||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/controller/EnvironmentAdminController.java
|
||||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AppController.java
|
||||
git add cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DeploymentController.java
|
||||
git commit -m "feat: add REST controllers for environment, app, and deployment management"
|
||||
```
|
||||
|
||||
@@ -918,7 +918,7 @@ git commit -m "feat: add REST controllers for environment, app, and deployment m
|
||||
### Task 10: Configuration and Application Properties
|
||||
|
||||
**Files:**
|
||||
- Modify: `cameleer3-server-app/src/main/resources/application.yml`
|
||||
- Modify: `cameleer-server-app/src/main/resources/application.yml`
|
||||
|
||||
- [x] **Step 1: Add runtime config properties**
|
||||
|
||||
@@ -939,13 +939,13 @@ cameleer:
|
||||
|
||||
- [x] **Step 2: Run full test suite**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify`
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn clean verify`
|
||||
Expected: PASS.
|
||||
|
||||
- [x] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/main/resources/application.yml
|
||||
git add cameleer-server-app/src/main/resources/application.yml
|
||||
git commit -m "feat: add runtime management configuration properties"
|
||||
```
|
||||
|
||||
@@ -968,7 +968,7 @@ Test deployment creation (with `DisabledRuntimeOrchestrator` — verifies the de
|
||||
- [x] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/
|
||||
git add cameleer-server-app/src/test/java/com/cameleer/server/app/controller/
|
||||
git commit -m "test: add integration tests for runtime management API"
|
||||
```
|
||||
|
||||
@@ -978,7 +978,7 @@ git commit -m "test: add integration tests for runtime management API"
|
||||
|
||||
- [x] **Step 1: Run full build**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify`
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn clean verify`
|
||||
Expected: All tests PASS.
|
||||
|
||||
- [x] **Step 2: Verify schema applies cleanly**
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
**Repo:** `C:\Users\Hendrik\Documents\projects\cameleer-saas`
|
||||
|
||||
**Prerequisite:** Plans 1-3 must be implemented in cameleer3-server first.
|
||||
**Prerequisite:** Plans 1-3 must be implemented in cameleer-server first.
|
||||
|
||||
---
|
||||
|
||||
@@ -212,7 +212,7 @@ git commit -m "feat: remove migrated environment/app/deployment/runtime code fro
|
||||
|
||||
```sql
|
||||
-- V010__drop_migrated_tables.sql
|
||||
-- Drop tables that have been migrated to cameleer3-server
|
||||
-- Drop tables that have been migrated to cameleer-server
|
||||
|
||||
DROP TABLE IF EXISTS deployments CASCADE;
|
||||
DROP TABLE IF EXISTS apps CASCADE;
|
||||
@@ -242,7 +242,7 @@ group_add:
|
||||
- "0"
|
||||
```
|
||||
|
||||
The Docker socket mount now belongs to the `cameleer3-server` service instead.
|
||||
The Docker socket mount now belongs to the `cameleer-server` service instead.
|
||||
|
||||
- [ ] **Step 2: Remove docker-java dependency from pom.xml**
|
||||
|
||||
@@ -328,7 +328,7 @@ git commit -m "feat: expand ServerApiClient with license push and health check m
|
||||
|
||||
- [ ] **Step 1: Create integration contract document**
|
||||
|
||||
Create `docs/SAAS-INTEGRATION.md` in the cameleer3-server repo documenting:
|
||||
Create `docs/SAAS-INTEGRATION.md` in the cameleer-server repo documenting:
|
||||
- Which server API endpoints the SaaS calls
|
||||
- Required auth (M2M token with `server:admin` scope)
|
||||
- License injection mechanism (`POST /api/v1/admin/license`)
|
||||
@@ -339,7 +339,7 @@ Create `docs/SAAS-INTEGRATION.md` in the cameleer3-server repo documenting:
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
cd /c/Users/Hendrik/Documents/projects/cameleer3-server
|
||||
cd /c/Users/Hendrik/Documents/projects/cameleer-server
|
||||
git add docs/SAAS-INTEGRATION.md
|
||||
git commit -m "docs: add SaaS integration contract documentation"
|
||||
```
|
||||
|
||||
3017
docs/superpowers/plans/2026-04-09-platform-redesign-plan.md
Normal file
@@ -581,7 +581,7 @@ In `ui/sign-in/src/SignInPage.tsx`, find the logo text (line ~61):
|
||||
// BEFORE:
|
||||
<div className={styles.logo}>
|
||||
<img src={cameleerLogo} alt="" className={styles.logoImg} />
|
||||
cameleer3
|
||||
cameleer
|
||||
</div>
|
||||
|
||||
// AFTER:
|
||||
|
||||
210
docs/superpowers/plans/2026-04-10-fleet-health-plan.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# Fleet Health at a Glance Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add agent count, environment count, and agent limit columns to the vendor tenant list so the vendor can see fleet utilization at a glance.
|
||||
|
||||
**Architecture:** Extend the existing `VendorTenantSummary` record with three int fields. The list endpoint fetches counts from each active tenant's server via existing M2M API methods (`getAgentCount`, `getEnvironmentCount`), parallelized with `CompletableFuture`. Frontend adds two columns (Agents, Envs) to the DataTable.
|
||||
|
||||
**Tech Stack:** Java 21, Spring Boot, CompletableFuture, React, TypeScript, @cameleer/design-system DataTable
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Extend backend — VendorTenantSummary + parallel fetch
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java`
|
||||
|
||||
- [ ] **Step 1: Extend the VendorTenantSummary record**
|
||||
|
||||
In `VendorTenantController.java`, replace the record at lines 39-48:
|
||||
|
||||
```java
|
||||
public record VendorTenantSummary(
|
||||
UUID id,
|
||||
String name,
|
||||
String slug,
|
||||
String tier,
|
||||
String status,
|
||||
String serverState,
|
||||
String licenseExpiry,
|
||||
String provisionError,
|
||||
int agentCount,
|
||||
int environmentCount,
|
||||
int agentLimit
|
||||
) {}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update the listAll() endpoint to fetch counts in parallel**
|
||||
|
||||
Replace the `listAll()` method at lines 60-77:
|
||||
|
||||
```java
|
||||
@GetMapping
|
||||
public ResponseEntity<List<VendorTenantSummary>> listAll() {
|
||||
var tenants = vendorTenantService.listAll();
|
||||
|
||||
// Parallel health fetch for active tenants
|
||||
var futures = tenants.stream().map(tenant -> java.util.concurrent.CompletableFuture.supplyAsync(() -> {
|
||||
ServerStatus status = vendorTenantService.getServerStatus(tenant);
|
||||
String licenseExpiry = vendorTenantService
|
||||
.getLicenseForTenant(tenant.getId())
|
||||
.map(l -> l.getExpiresAt() != null ? l.getExpiresAt().toString() : null)
|
||||
.orElse(null);
|
||||
|
||||
int agentCount = 0;
|
||||
int environmentCount = 0;
|
||||
int agentLimit = -1;
|
||||
|
||||
String endpoint = tenant.getServerEndpoint();
|
||||
boolean isActive = "ACTIVE".equals(tenant.getStatus().name());
|
||||
if (isActive && endpoint != null && !endpoint.isBlank() && "RUNNING".equals(status.state().name())) {
|
||||
var serverApi = vendorTenantService.getServerApiClient();
|
||||
agentCount = serverApi.getAgentCount(endpoint);
|
||||
environmentCount = serverApi.getEnvironmentCount(endpoint);
|
||||
}
|
||||
|
||||
var license = vendorTenantService.getLicenseForTenant(tenant.getId());
|
||||
if (license.isPresent() && license.get().getLimits() != null) {
|
||||
var limits = license.get().getLimits();
|
||||
if (limits.containsKey("agents")) {
|
||||
agentLimit = ((Number) limits.get("agents")).intValue();
|
||||
}
|
||||
}
|
||||
|
||||
return new VendorTenantSummary(
|
||||
tenant.getId(), tenant.getName(), tenant.getSlug(),
|
||||
tenant.getTier().name(), tenant.getStatus().name(),
|
||||
status.state().name(), licenseExpiry, tenant.getProvisionError(),
|
||||
agentCount, environmentCount, agentLimit
|
||||
);
|
||||
})).toList();
|
||||
|
||||
List<VendorTenantSummary> summaries = futures.stream()
|
||||
.map(java.util.concurrent.CompletableFuture::join)
|
||||
.toList();
|
||||
|
||||
return ResponseEntity.ok(summaries);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Expose ServerApiClient from VendorTenantService**
|
||||
|
||||
Add a getter in `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java`:
|
||||
|
||||
```java
|
||||
public ServerApiClient getServerApiClient() {
|
||||
return serverApiClient;
|
||||
}
|
||||
```
|
||||
|
||||
(The `serverApiClient` field already exists in VendorTenantService — check around line 30.)
|
||||
|
||||
- [ ] **Step 4: Verify compilation**
|
||||
|
||||
Run: `./mvnw compile -pl . -q`
|
||||
Expected: BUILD SUCCESS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java \
|
||||
src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java
|
||||
git commit -m "feat: add agent/env counts to vendor tenant list endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Update frontend types and columns
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui/src/types/api.ts`
|
||||
- Modify: `ui/src/pages/vendor/VendorTenantsPage.tsx`
|
||||
|
||||
- [ ] **Step 1: Add fields to VendorTenantSummary TypeScript type**
|
||||
|
||||
In `ui/src/types/api.ts`, update the `VendorTenantSummary` interface:
|
||||
|
||||
```typescript
|
||||
export interface VendorTenantSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
tier: string;
|
||||
status: string;
|
||||
serverState: string;
|
||||
licenseExpiry: string | null;
|
||||
provisionError: string | null;
|
||||
agentCount: number;
|
||||
environmentCount: number;
|
||||
agentLimit: number;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add Agents and Envs columns to VendorTenantsPage**
|
||||
|
||||
In `ui/src/pages/vendor/VendorTenantsPage.tsx`, add a helper function after `statusColor`:
|
||||
|
||||
```typescript
|
||||
function formatUsage(used: number, limit: number): string {
|
||||
return limit < 0 ? `${used} / ∞` : `${used} / ${limit}`;
|
||||
}
|
||||
```
|
||||
|
||||
Then add two column entries in the `columns` array, after the `serverState` column (after line 54) and before the `licenseExpiry` column:
|
||||
|
||||
```typescript
|
||||
{
|
||||
key: 'agentCount',
|
||||
header: 'Agents',
|
||||
render: (_v, row) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}>
|
||||
{formatUsage(row.agentCount, row.agentLimit)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'environmentCount',
|
||||
header: 'Envs',
|
||||
render: (_v, row) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}>
|
||||
{row.environmentCount}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build the UI**
|
||||
|
||||
Run: `cd ui && npm run build`
|
||||
Expected: Build succeeds with no errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add ui/src/types/api.ts ui/src/pages/vendor/VendorTenantsPage.tsx
|
||||
git commit -m "feat: show agent/env counts in vendor tenant list"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Verify end-to-end
|
||||
|
||||
- [ ] **Step 1: Run backend tests**
|
||||
|
||||
Run: `./mvnw test -pl . -q`
|
||||
Expected: All tests pass. (Existing tests use mocks, the new parallel fetch doesn't break them since it only affects the controller's list mapping.)
|
||||
|
||||
- [ ] **Step 2: Verify in browser**
|
||||
|
||||
Navigate to the vendor tenant list. Confirm:
|
||||
- "Agents" column shows "0 / ∞" (or actual count if agents are connected)
|
||||
- "Envs" column shows "1" (or actual count)
|
||||
- PROVISIONING/SUSPENDED tenants show "0" for both
|
||||
- 30s auto-refresh still works
|
||||
|
||||
- [ ] **Step 3: Final commit and push**
|
||||
|
||||
```bash
|
||||
git push
|
||||
```
|
||||
2662
docs/superpowers/plans/2026-04-13-install-script-plan.md
Normal file
@@ -0,0 +1,961 @@
|
||||
# Externalize Docker Compose Templates — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace inline docker-compose generation in installer scripts with static template files, reducing duplication and enabling user customization.
|
||||
|
||||
**Architecture:** Static YAML templates in `installer/templates/` are copied to the install directory. The installer writes `.env` (including `COMPOSE_FILE` to select which templates are active) and runs `docker compose up -d`. Conditional features (TLS, monitoring) are handled via compose file layering and `.env` variables instead of heredoc injection.
|
||||
|
||||
**Tech Stack:** Docker Compose v2, YAML, Bash, PowerShell
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-15-externalize-compose-templates-design.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create `docker-compose.yml` (infra base template)
|
||||
|
||||
**Files:**
|
||||
- Create: `installer/templates/docker-compose.yml`
|
||||
|
||||
This is the shared infrastructure base — always loaded regardless of deployment mode.
|
||||
|
||||
- [ ] **Step 1: Create the infra base template**
|
||||
|
||||
```yaml
|
||||
# Cameleer Infrastructure
|
||||
# Shared base — always loaded. Mode-specific services in separate compose files.
|
||||
|
||||
services:
|
||||
cameleer-traefik:
|
||||
image: ${TRAEFIK_IMAGE:-gitea.siegeln.net/cameleer/cameleer-traefik}:${VERSION:-latest}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${HTTP_PORT:-80}:80"
|
||||
- "${HTTPS_PORT:-443}:443"
|
||||
- "${LOGTO_CONSOLE_BIND:-127.0.0.1}:${LOGTO_CONSOLE_PORT:-3002}:3002"
|
||||
environment:
|
||||
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
|
||||
CERT_FILE: ${CERT_FILE:-}
|
||||
KEY_FILE: ${KEY_FILE:-}
|
||||
CA_FILE: ${CA_FILE:-}
|
||||
volumes:
|
||||
- cameleer-certs:/certs
|
||||
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock:ro
|
||||
labels:
|
||||
- "prometheus.io/scrape=true"
|
||||
- "prometheus.io/port=8082"
|
||||
- "prometheus.io/path=/metrics"
|
||||
networks:
|
||||
- cameleer
|
||||
- cameleer-traefik
|
||||
- monitoring
|
||||
|
||||
cameleer-postgres:
|
||||
image: ${POSTGRES_IMAGE:-gitea.siegeln.net/cameleer/cameleer-postgres}:${VERSION:-latest}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-cameleer_saas}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-cameleer}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in .env}
|
||||
volumes:
|
||||
- cameleer-pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-cameleer} -d $${POSTGRES_DB:-cameleer_saas}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- cameleer
|
||||
- monitoring
|
||||
|
||||
cameleer-clickhouse:
|
||||
image: ${CLICKHOUSE_IMAGE:-gitea.siegeln.net/cameleer/cameleer-clickhouse}:${VERSION:-latest}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:?CLICKHOUSE_PASSWORD must be set in .env}
|
||||
volumes:
|
||||
- cameleer-chdata:/var/lib/clickhouse
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "clickhouse-client --password $${CLICKHOUSE_PASSWORD} --query 'SELECT 1'"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
labels:
|
||||
- "prometheus.io/scrape=true"
|
||||
- "prometheus.io/port=9363"
|
||||
- "prometheus.io/path=/metrics"
|
||||
networks:
|
||||
- cameleer
|
||||
- monitoring
|
||||
|
||||
volumes:
|
||||
cameleer-pgdata:
|
||||
cameleer-chdata:
|
||||
cameleer-certs:
|
||||
|
||||
networks:
|
||||
cameleer:
|
||||
driver: bridge
|
||||
cameleer-traefik:
|
||||
name: cameleer-traefik
|
||||
driver: bridge
|
||||
monitoring:
|
||||
name: cameleer-monitoring-noop
|
||||
```
|
||||
|
||||
Key changes from the generated version:
|
||||
- Logto console port always present with `LOGTO_CONSOLE_BIND` controlling exposure
|
||||
- Prometheus labels unconditional on traefik and clickhouse
|
||||
- `monitoring` network defined as local noop bridge
|
||||
- All services join `monitoring` network
|
||||
- `POSTGRES_DB` uses `${POSTGRES_DB:-cameleer_saas}` (parameterized — standalone overrides via `.env`)
|
||||
- Password variables use `:?` fail-if-unset
|
||||
|
||||
Note: The SaaS mode uses `cameleer-postgres` (custom multi-DB image) while standalone uses `postgres:16-alpine`. The `POSTGRES_IMAGE` variable already handles this — the infra base uses `${POSTGRES_IMAGE:-...}` and standalone `.env` sets `POSTGRES_IMAGE=postgres:16-alpine`.
|
||||
|
||||
- [ ] **Step 2: Verify YAML is valid**
|
||||
|
||||
Run: `python -c "import yaml; yaml.safe_load(open('installer/templates/docker-compose.yml'))"`
|
||||
Expected: No output (valid YAML). If python/yaml not available, use `docker compose -f installer/templates/docker-compose.yml config --quiet` (will fail on unset vars, but validates structure).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add installer/templates/docker-compose.yml
|
||||
git commit -m "feat(installer): add infra base docker-compose template"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create `docker-compose.saas.yml` (SaaS mode template)
|
||||
|
||||
**Files:**
|
||||
- Create: `installer/templates/docker-compose.saas.yml`
|
||||
|
||||
SaaS-specific services: Logto identity provider and cameleer-saas management plane.
|
||||
|
||||
- [ ] **Step 1: Create the SaaS template**
|
||||
|
||||
```yaml
|
||||
# Cameleer SaaS — Logto + management plane
|
||||
# Loaded in SaaS deployment mode
|
||||
|
||||
services:
|
||||
cameleer-logto:
|
||||
image: ${LOGTO_IMAGE:-gitea.siegeln.net/cameleer/cameleer-logto}:${VERSION:-latest}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
cameleer-postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DB_URL: postgres://${POSTGRES_USER:-cameleer}:${POSTGRES_PASSWORD}@cameleer-postgres:5432/logto
|
||||
ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
||||
ADMIN_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}
|
||||
TRUST_PROXY_HEADER: 1
|
||||
NODE_TLS_REJECT_UNAUTHORIZED: "${NODE_TLS_REJECT:-0}"
|
||||
LOGTO_ENDPOINT: http://cameleer-logto:3001
|
||||
LOGTO_ADMIN_ENDPOINT: http://cameleer-logto:3002
|
||||
LOGTO_PUBLIC_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
||||
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
|
||||
PUBLIC_PROTOCOL: ${PUBLIC_PROTOCOL:-https}
|
||||
PG_HOST: cameleer-postgres
|
||||
PG_USER: ${POSTGRES_USER:-cameleer}
|
||||
PG_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
PG_DB_SAAS: cameleer_saas
|
||||
SAAS_ADMIN_USER: ${SAAS_ADMIN_USER:-admin}
|
||||
SAAS_ADMIN_PASS: ${SAAS_ADMIN_PASS:?SAAS_ADMIN_PASS must be set in .env}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\" && test -f /data/logto-bootstrap.json"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 60
|
||||
start_period: 30s
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.cameleer-logto.rule=PathPrefix(`/`)
|
||||
- traefik.http.routers.cameleer-logto.priority=1
|
||||
- traefik.http.routers.cameleer-logto.entrypoints=websecure
|
||||
- traefik.http.routers.cameleer-logto.tls=true
|
||||
- traefik.http.routers.cameleer-logto.service=cameleer-logto
|
||||
- traefik.http.routers.cameleer-logto.middlewares=cameleer-logto-cors
|
||||
- "traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowOriginList=${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}"
|
||||
- traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowMethods=GET,POST,PUT,PATCH,DELETE,OPTIONS
|
||||
- traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowHeaders=Authorization,Content-Type
|
||||
- traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowCredentials=true
|
||||
- traefik.http.services.cameleer-logto.loadbalancer.server.port=3001
|
||||
- traefik.http.routers.cameleer-logto-console.rule=PathPrefix(`/`)
|
||||
- traefik.http.routers.cameleer-logto-console.entrypoints=admin-console
|
||||
- traefik.http.routers.cameleer-logto-console.tls=true
|
||||
- traefik.http.routers.cameleer-logto-console.service=cameleer-logto-console
|
||||
- traefik.http.services.cameleer-logto-console.loadbalancer.server.port=3002
|
||||
volumes:
|
||||
- cameleer-bootstrapdata:/data
|
||||
networks:
|
||||
- cameleer
|
||||
- monitoring
|
||||
|
||||
cameleer-saas:
|
||||
image: ${CAMELEER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-saas}:${VERSION:-latest}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
cameleer-logto:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# SaaS database
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://cameleer-postgres:5432/cameleer_saas
|
||||
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
|
||||
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
# Identity (Logto)
|
||||
CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT: http://cameleer-logto:3001
|
||||
CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
||||
# Provisioning — passed to per-tenant server containers
|
||||
CAMELEER_SAAS_PROVISIONING_PUBLICHOST: ${PUBLIC_HOST:-localhost}
|
||||
CAMELEER_SAAS_PROVISIONING_PUBLICPROTOCOL: ${PUBLIC_PROTOCOL:-https}
|
||||
CAMELEER_SAAS_PROVISIONING_NETWORKNAME: ${COMPOSE_PROJECT_NAME:-cameleer-saas}_cameleer
|
||||
CAMELEER_SAAS_PROVISIONING_TRAEFIKNETWORK: cameleer-traefik
|
||||
CAMELEER_SAAS_PROVISIONING_DATASOURCEUSERNAME: ${POSTGRES_USER:-cameleer}
|
||||
CAMELEER_SAAS_PROVISIONING_DATASOURCEPASSWORD: ${POSTGRES_PASSWORD}
|
||||
CAMELEER_SAAS_PROVISIONING_CLICKHOUSEPASSWORD: ${CLICKHOUSE_PASSWORD}
|
||||
CAMELEER_SAAS_PROVISIONING_SERVERIMAGE: ${CAMELEER_SAAS_PROVISIONING_SERVERIMAGE:-gitea.siegeln.net/cameleer/cameleer-server:latest}
|
||||
CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE: ${CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE:-gitea.siegeln.net/cameleer/cameleer-server-ui:latest}
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.saas.rule=PathPrefix(`/platform`)
|
||||
- traefik.http.routers.saas.entrypoints=websecure
|
||||
- traefik.http.routers.saas.tls=true
|
||||
- traefik.http.services.saas.loadbalancer.server.port=8080
|
||||
- "prometheus.io/scrape=true"
|
||||
- "prometheus.io/port=8080"
|
||||
- "prometheus.io/path=/platform/actuator/prometheus"
|
||||
volumes:
|
||||
- cameleer-bootstrapdata:/data/bootstrap:ro
|
||||
- cameleer-certs:/certs
|
||||
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
|
||||
group_add:
|
||||
- "${DOCKER_GID:-0}"
|
||||
networks:
|
||||
- cameleer
|
||||
- monitoring
|
||||
|
||||
volumes:
|
||||
cameleer-bootstrapdata:
|
||||
|
||||
networks:
|
||||
monitoring:
|
||||
name: cameleer-monitoring-noop
|
||||
```
|
||||
|
||||
Key changes:
|
||||
- Logto console traefik labels always included (harmless when port is localhost-only)
|
||||
- Prometheus labels on cameleer-saas always included
|
||||
- `DOCKER_GID` read from `.env` via `${DOCKER_GID:-0}` instead of inline `stat`
|
||||
- Both services join `monitoring` network
|
||||
- `monitoring` network redefined as noop bridge (compose merges with base definition)
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add installer/templates/docker-compose.saas.yml
|
||||
git commit -m "feat(installer): add SaaS docker-compose template"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Create `docker-compose.server.yml` (standalone mode template)
|
||||
|
||||
**Files:**
|
||||
- Create: `installer/templates/docker-compose.server.yml`
|
||||
- Create: `installer/templates/traefik-dynamic.yml`
|
||||
|
||||
Standalone-specific services: cameleer-server + server-ui. Also includes the traefik dynamic config that standalone mode needs (overrides the baked-in SaaS redirect).
|
||||
|
||||
- [ ] **Step 1: Create the standalone template**
|
||||
|
||||
```yaml
|
||||
# Cameleer Server (standalone)
|
||||
# Loaded in standalone deployment mode
|
||||
|
||||
services:
|
||||
cameleer-traefik:
|
||||
volumes:
|
||||
- ./traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
|
||||
|
||||
cameleer-postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-cameleer}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-cameleer} -d $${POSTGRES_DB:-cameleer}"]
|
||||
|
||||
cameleer-server:
|
||||
image: ${SERVER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-server}:${VERSION:-latest}
|
||||
container_name: cameleer-server
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
cameleer-postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
CAMELEER_SERVER_TENANT_ID: default
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://cameleer-postgres:5432/${POSTGRES_DB:-cameleer}?currentSchema=tenant_default
|
||||
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
|
||||
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
CAMELEER_SERVER_CLICKHOUSE_URL: jdbc:clickhouse://cameleer-clickhouse:8123/cameleer
|
||||
CAMELEER_SERVER_CLICKHOUSE_USERNAME: default
|
||||
CAMELEER_SERVER_CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD}
|
||||
CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKEN: ${BOOTSTRAP_TOKEN:?BOOTSTRAP_TOKEN must be set in .env}
|
||||
CAMELEER_SERVER_SECURITY_UIUSER: ${SERVER_ADMIN_USER:-admin}
|
||||
CAMELEER_SERVER_SECURITY_UIPASSWORD: ${SERVER_ADMIN_PASS:?SERVER_ADMIN_PASS must be set in .env}
|
||||
CAMELEER_SERVER_SECURITY_CORSALLOWEDORIGINS: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
||||
CAMELEER_SERVER_RUNTIME_ENABLED: "true"
|
||||
CAMELEER_SERVER_RUNTIME_SERVERURL: http://cameleer-server:8081
|
||||
CAMELEER_SERVER_RUNTIME_ROUTINGDOMAIN: ${PUBLIC_HOST:-localhost}
|
||||
CAMELEER_SERVER_RUNTIME_ROUTINGMODE: path
|
||||
CAMELEER_SERVER_RUNTIME_JARSTORAGEPATH: /data/jars
|
||||
CAMELEER_SERVER_RUNTIME_DOCKERNETWORK: cameleer-apps
|
||||
CAMELEER_SERVER_RUNTIME_JARDOCKERVOLUME: cameleer-jars
|
||||
CAMELEER_SERVER_RUNTIME_BASEIMAGE: gitea.siegeln.net/cameleer/cameleer-runtime-base:${VERSION:-latest}
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.server-api.rule=PathPrefix(`/api`)
|
||||
- traefik.http.routers.server-api.entrypoints=websecure
|
||||
- traefik.http.routers.server-api.tls=true
|
||||
- traefik.http.services.server-api.loadbalancer.server.port=8081
|
||||
- traefik.docker.network=cameleer-traefik
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:8081/api/v1/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 30s
|
||||
volumes:
|
||||
- jars:/data/jars
|
||||
- cameleer-certs:/certs:ro
|
||||
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
|
||||
group_add:
|
||||
- "${DOCKER_GID:-0}"
|
||||
networks:
|
||||
- cameleer
|
||||
- cameleer-traefik
|
||||
- cameleer-apps
|
||||
- monitoring
|
||||
|
||||
cameleer-server-ui:
|
||||
image: ${SERVER_UI_IMAGE:-gitea.siegeln.net/cameleer/cameleer-server-ui}:${VERSION:-latest}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
cameleer-server:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
CAMELEER_API_URL: http://cameleer-server:8081
|
||||
BASE_PATH: ""
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.ui.rule=PathPrefix(`/`)
|
||||
- traefik.http.routers.ui.priority=1
|
||||
- traefik.http.routers.ui.entrypoints=websecure
|
||||
- traefik.http.routers.ui.tls=true
|
||||
- traefik.http.services.ui.loadbalancer.server.port=80
|
||||
- traefik.docker.network=cameleer-traefik
|
||||
networks:
|
||||
- cameleer-traefik
|
||||
- monitoring
|
||||
|
||||
volumes:
|
||||
jars:
|
||||
|
||||
networks:
|
||||
cameleer-apps:
|
||||
name: cameleer-apps
|
||||
driver: bridge
|
||||
monitoring:
|
||||
name: cameleer-monitoring-noop
|
||||
```
|
||||
|
||||
Key design decisions:
|
||||
- `cameleer-traefik` and `cameleer-postgres` entries are **overrides** — compose merges them with the base. The postgres image switches to `postgres:16-alpine` and the healthcheck uses `${POSTGRES_DB:-cameleer}` instead of hardcoded `cameleer_saas`. Traefik gets the `traefik-dynamic.yml` volume mount.
|
||||
- `DOCKER_GID` from `.env` via `${DOCKER_GID:-0}`
|
||||
- `BOOTSTRAP_TOKEN` uses `:?` fail-if-unset
|
||||
- Both server and server-ui join `monitoring` network
|
||||
|
||||
- [ ] **Step 2: Create the traefik dynamic config template**
|
||||
|
||||
```yaml
|
||||
tls:
|
||||
stores:
|
||||
default:
|
||||
defaultCertificate:
|
||||
certFile: /certs/cert.pem
|
||||
keyFile: /certs/key.pem
|
||||
```
|
||||
|
||||
This file is only relevant in standalone mode (overrides the baked-in SaaS `/` -> `/platform/` redirect in the traefik image).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add installer/templates/docker-compose.server.yml installer/templates/traefik-dynamic.yml
|
||||
git commit -m "feat(installer): add standalone docker-compose and traefik templates"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Create overlay templates (TLS + monitoring)
|
||||
|
||||
**Files:**
|
||||
- Create: `installer/templates/docker-compose.tls.yml`
|
||||
- Create: `installer/templates/docker-compose.monitoring.yml`
|
||||
|
||||
- [ ] **Step 1: Create the TLS overlay**
|
||||
|
||||
```yaml
|
||||
# Custom TLS certificates overlay
|
||||
# Adds user-supplied certificate volume to traefik
|
||||
|
||||
services:
|
||||
cameleer-traefik:
|
||||
volumes:
|
||||
- ./certs:/user-certs:ro
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the monitoring overlay**
|
||||
|
||||
```yaml
|
||||
# External monitoring network overlay
|
||||
# Overrides the noop monitoring bridge with a real external network
|
||||
|
||||
networks:
|
||||
monitoring:
|
||||
external: true
|
||||
name: ${MONITORING_NETWORK:?MONITORING_NETWORK must be set in .env}
|
||||
```
|
||||
|
||||
This is the key to the monitoring pattern: the base compose files define `monitoring` as a local noop bridge and all services join it. When this overlay is included in `COMPOSE_FILE`, compose merges the network definition — overriding it to point at the real external monitoring network. No per-service entries needed.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add installer/templates/docker-compose.tls.yml installer/templates/docker-compose.monitoring.yml
|
||||
git commit -m "feat(installer): add TLS and monitoring overlay templates"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Create `.env.example`
|
||||
|
||||
**Files:**
|
||||
- Create: `installer/templates/.env.example`
|
||||
|
||||
- [ ] **Step 1: Create the documented variable reference**
|
||||
|
||||
```bash
|
||||
# Cameleer Configuration
|
||||
# Copy this file to .env and fill in the values.
|
||||
# The installer generates .env automatically — this file is for reference.
|
||||
|
||||
# ============================================================
|
||||
# Compose file assembly (set by installer)
|
||||
# ============================================================
|
||||
# SaaS: docker-compose.yml:docker-compose.saas.yml
|
||||
# Standalone: docker-compose.yml:docker-compose.server.yml
|
||||
# Add :docker-compose.tls.yml for custom TLS certificates
|
||||
# Add :docker-compose.monitoring.yml for external monitoring network
|
||||
COMPOSE_FILE=docker-compose.yml:docker-compose.saas.yml
|
||||
|
||||
# ============================================================
|
||||
# Image version
|
||||
# ============================================================
|
||||
VERSION=latest
|
||||
|
||||
# ============================================================
|
||||
# Public access
|
||||
# ============================================================
|
||||
PUBLIC_HOST=localhost
|
||||
PUBLIC_PROTOCOL=https
|
||||
|
||||
# ============================================================
|
||||
# Ports
|
||||
# ============================================================
|
||||
HTTP_PORT=80
|
||||
HTTPS_PORT=443
|
||||
# Set to 0.0.0.0 to expose Logto admin console externally (default: localhost only)
|
||||
# LOGTO_CONSOLE_BIND=0.0.0.0
|
||||
LOGTO_CONSOLE_PORT=3002
|
||||
|
||||
# ============================================================
|
||||
# PostgreSQL
|
||||
# ============================================================
|
||||
POSTGRES_USER=cameleer
|
||||
POSTGRES_PASSWORD=CHANGE_ME
|
||||
# SaaS: cameleer_saas, Standalone: cameleer
|
||||
POSTGRES_DB=cameleer_saas
|
||||
|
||||
# ============================================================
|
||||
# ClickHouse
|
||||
# ============================================================
|
||||
CLICKHOUSE_PASSWORD=CHANGE_ME
|
||||
|
||||
# ============================================================
|
||||
# Admin credentials (SaaS mode)
|
||||
# ============================================================
|
||||
SAAS_ADMIN_USER=admin
|
||||
SAAS_ADMIN_PASS=CHANGE_ME
|
||||
|
||||
# ============================================================
|
||||
# Admin credentials (standalone mode)
|
||||
# ============================================================
|
||||
# SERVER_ADMIN_USER=admin
|
||||
# SERVER_ADMIN_PASS=CHANGE_ME
|
||||
# BOOTSTRAP_TOKEN=CHANGE_ME
|
||||
|
||||
# ============================================================
|
||||
# TLS
|
||||
# ============================================================
|
||||
# Set to 1 to reject unauthorized TLS certificates (production)
|
||||
NODE_TLS_REJECT=0
|
||||
# Custom TLS certificate paths (inside container, set by installer)
|
||||
# CERT_FILE=/user-certs/cert.pem
|
||||
# KEY_FILE=/user-certs/key.pem
|
||||
# CA_FILE=/user-certs/ca.pem
|
||||
|
||||
# ============================================================
|
||||
# Docker
|
||||
# ============================================================
|
||||
DOCKER_SOCKET=/var/run/docker.sock
|
||||
# GID of the docker socket — detected by installer, used for container group_add
|
||||
DOCKER_GID=0
|
||||
|
||||
# ============================================================
|
||||
# Provisioning images (SaaS mode only)
|
||||
# ============================================================
|
||||
# CAMELEER_SAAS_PROVISIONING_SERVERIMAGE=gitea.siegeln.net/cameleer/cameleer-server:latest
|
||||
# CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE=gitea.siegeln.net/cameleer/cameleer-server-ui:latest
|
||||
|
||||
# ============================================================
|
||||
# Monitoring (optional)
|
||||
# ============================================================
|
||||
# External Docker network name for Prometheus scraping.
|
||||
# Only needed when docker-compose.monitoring.yml is in COMPOSE_FILE.
|
||||
# MONITORING_NETWORK=prometheus
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add installer/templates/.env.example
|
||||
git commit -m "feat(installer): add .env.example with documented variables"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Update `install.sh` — replace compose generation with template copying
|
||||
|
||||
**Files:**
|
||||
- Modify: `installer/install.sh:574-672` (generate_env_file — add COMPOSE_FILE and LOGTO_CONSOLE_BIND)
|
||||
- Modify: `installer/install.sh:674-1135` (replace generate_compose_file + generate_compose_file_standalone with copy_templates)
|
||||
- Modify: `installer/install.sh:1728-1731` (reinstall cleanup — delete template files)
|
||||
- Modify: `installer/install.sh:1696-1710` (upgrade path — copy templates instead of generate)
|
||||
- Modify: `installer/install.sh:1790-1791` (main — call copy_templates instead of generate_compose_file)
|
||||
|
||||
- [ ] **Step 1: Replace `generate_compose_file` and `generate_compose_file_standalone` with `copy_templates`**
|
||||
|
||||
Delete both functions (`generate_compose_file` at line 674 and `generate_compose_file_standalone` at line 934) and replace with:
|
||||
|
||||
```bash
|
||||
copy_templates() {
|
||||
local src
|
||||
src="$(cd "$(dirname "$0")" && pwd)/templates"
|
||||
|
||||
# Base infra — always copied
|
||||
cp "$src/docker-compose.yml" "$INSTALL_DIR/docker-compose.yml"
|
||||
cp "$src/.env.example" "$INSTALL_DIR/.env.example"
|
||||
|
||||
# Mode-specific
|
||||
if [ "$DEPLOYMENT_MODE" = "standalone" ]; then
|
||||
cp "$src/docker-compose.server.yml" "$INSTALL_DIR/docker-compose.server.yml"
|
||||
cp "$src/traefik-dynamic.yml" "$INSTALL_DIR/traefik-dynamic.yml"
|
||||
else
|
||||
cp "$src/docker-compose.saas.yml" "$INSTALL_DIR/docker-compose.saas.yml"
|
||||
fi
|
||||
|
||||
# Optional overlays
|
||||
if [ "$TLS_MODE" = "custom" ]; then
|
||||
cp "$src/docker-compose.tls.yml" "$INSTALL_DIR/docker-compose.tls.yml"
|
||||
fi
|
||||
if [ -n "$MONITORING_NETWORK" ]; then
|
||||
cp "$src/docker-compose.monitoring.yml" "$INSTALL_DIR/docker-compose.monitoring.yml"
|
||||
fi
|
||||
|
||||
log_info "Copied docker-compose templates to $INSTALL_DIR"
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `generate_env_file` to include `COMPOSE_FILE`, `LOGTO_CONSOLE_BIND`, and `DOCKER_GID`**
|
||||
|
||||
In the standalone `.env` block (line 577-614), add after the `DOCKER_GID` line:
|
||||
|
||||
```bash
|
||||
# Compose file assembly
|
||||
COMPOSE_FILE=docker-compose.yml:docker-compose.server.yml$([ "$TLS_MODE" = "custom" ] && echo ":docker-compose.tls.yml")$([ -n "$MONITORING_NETWORK" ] && echo ":docker-compose.monitoring.yml")
|
||||
EOF
|
||||
```
|
||||
|
||||
In the SaaS `.env` block (line 617-668), add `LOGTO_CONSOLE_BIND` and `COMPOSE_FILE`. After the `LOGTO_CONSOLE_PORT` line:
|
||||
|
||||
```bash
|
||||
LOGTO_CONSOLE_BIND=$([ "$LOGTO_CONSOLE_EXPOSED" = "true" ] && echo "0.0.0.0" || echo "127.0.0.1")
|
||||
```
|
||||
|
||||
And at the end of the SaaS block, add the `COMPOSE_FILE` line:
|
||||
|
||||
```bash
|
||||
# Compose file assembly
|
||||
COMPOSE_FILE=docker-compose.yml:docker-compose.saas.yml$([ "$TLS_MODE" = "custom" ] && echo ":docker-compose.tls.yml")$([ -n "$MONITORING_NETWORK" ] && echo ":docker-compose.monitoring.yml")
|
||||
```
|
||||
|
||||
Also add the `MONITORING_NETWORK` variable to `.env` when set:
|
||||
|
||||
```bash
|
||||
if [ -n "$MONITORING_NETWORK" ]; then
|
||||
echo "" >> "$f"
|
||||
echo "# Monitoring" >> "$f"
|
||||
echo "MONITORING_NETWORK=${MONITORING_NETWORK}" >> "$f"
|
||||
fi
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `main()` — replace `generate_compose_file` call with `copy_templates`**
|
||||
|
||||
At line 1791, change:
|
||||
```bash
|
||||
generate_compose_file
|
||||
```
|
||||
to:
|
||||
```bash
|
||||
copy_templates
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `handle_rerun` upgrade path**
|
||||
|
||||
At line 1703, change:
|
||||
```bash
|
||||
generate_compose_file
|
||||
```
|
||||
to:
|
||||
```bash
|
||||
copy_templates
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update reinstall cleanup to remove template files**
|
||||
|
||||
At lines 1728-1731, update the `rm -f` list to include all possible template files:
|
||||
```bash
|
||||
rm -f "$INSTALL_DIR/.env" "$INSTALL_DIR/.env.bak" "$INSTALL_DIR/.env.example" \
|
||||
"$INSTALL_DIR/docker-compose.yml" "$INSTALL_DIR/docker-compose.saas.yml" \
|
||||
"$INSTALL_DIR/docker-compose.server.yml" "$INSTALL_DIR/docker-compose.tls.yml" \
|
||||
"$INSTALL_DIR/docker-compose.monitoring.yml" "$INSTALL_DIR/traefik-dynamic.yml" \
|
||||
"$INSTALL_DIR/cameleer.conf" "$INSTALL_DIR/credentials.txt" \
|
||||
"$INSTALL_DIR/INSTALL.md"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add installer/install.sh
|
||||
git commit -m "refactor(installer): replace sh compose generation with template copying"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Update `install.ps1` — replace compose generation with template copying
|
||||
|
||||
**Files:**
|
||||
- Modify: `installer/install.ps1:574-666` (Generate-EnvFile — add COMPOSE_FILE and LOGTO_CONSOLE_BIND)
|
||||
- Modify: `installer/install.ps1:671-1105` (replace Generate-ComposeFile + Generate-ComposeFileStandalone with Copy-Templates)
|
||||
- Modify: `installer/install.ps1:1706-1723` (upgrade path)
|
||||
- Modify: `installer/install.ps1:1746` (reinstall cleanup)
|
||||
- Modify: `installer/install.ps1:1797-1798` (Main — call Copy-Templates)
|
||||
|
||||
- [ ] **Step 1: Replace `Generate-ComposeFile` and `Generate-ComposeFileStandalone` with `Copy-Templates`**
|
||||
|
||||
Delete both functions and replace with:
|
||||
|
||||
```powershell
|
||||
function Copy-Templates {
|
||||
$c = $script:cfg
|
||||
$src = Join-Path $PSScriptRoot 'templates'
|
||||
|
||||
# Base infra — always copied
|
||||
Copy-Item (Join-Path $src 'docker-compose.yml') (Join-Path $c.InstallDir 'docker-compose.yml') -Force
|
||||
Copy-Item (Join-Path $src '.env.example') (Join-Path $c.InstallDir '.env.example') -Force
|
||||
|
||||
# Mode-specific
|
||||
if ($c.DeploymentMode -eq 'standalone') {
|
||||
Copy-Item (Join-Path $src 'docker-compose.server.yml') (Join-Path $c.InstallDir 'docker-compose.server.yml') -Force
|
||||
Copy-Item (Join-Path $src 'traefik-dynamic.yml') (Join-Path $c.InstallDir 'traefik-dynamic.yml') -Force
|
||||
} else {
|
||||
Copy-Item (Join-Path $src 'docker-compose.saas.yml') (Join-Path $c.InstallDir 'docker-compose.saas.yml') -Force
|
||||
}
|
||||
|
||||
# Optional overlays
|
||||
if ($c.TlsMode -eq 'custom') {
|
||||
Copy-Item (Join-Path $src 'docker-compose.tls.yml') (Join-Path $c.InstallDir 'docker-compose.tls.yml') -Force
|
||||
}
|
||||
if ($c.MonitoringNetwork) {
|
||||
Copy-Item (Join-Path $src 'docker-compose.monitoring.yml') (Join-Path $c.InstallDir 'docker-compose.monitoring.yml') -Force
|
||||
}
|
||||
|
||||
Log-Info "Copied docker-compose templates to $($c.InstallDir)"
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `Generate-EnvFile` to include `COMPOSE_FILE`, `LOGTO_CONSOLE_BIND`, and `MONITORING_NETWORK`**
|
||||
|
||||
In the standalone `.env` content block, add after `DOCKER_GID`:
|
||||
|
||||
```powershell
|
||||
$composeFile = 'docker-compose.yml:docker-compose.server.yml'
|
||||
if ($c.TlsMode -eq 'custom') { $composeFile += ':docker-compose.tls.yml' }
|
||||
if ($c.MonitoringNetwork) { $composeFile += ':docker-compose.monitoring.yml' }
|
||||
```
|
||||
|
||||
Then append to `$content`:
|
||||
```powershell
|
||||
$content += "`n`n# Compose file assembly`nCOMPOSE_FILE=$composeFile"
|
||||
if ($c.MonitoringNetwork) {
|
||||
$content += "`n`n# Monitoring`nMONITORING_NETWORK=$($c.MonitoringNetwork)"
|
||||
}
|
||||
```
|
||||
|
||||
In the SaaS `.env` content block, add `LOGTO_CONSOLE_BIND` after `LOGTO_CONSOLE_PORT`:
|
||||
|
||||
```powershell
|
||||
$consoleBind = if ($c.LogtoConsoleExposed -eq 'true') { '0.0.0.0' } else { '127.0.0.1' }
|
||||
```
|
||||
|
||||
Add to the content string: `LOGTO_CONSOLE_BIND=$consoleBind`
|
||||
|
||||
Build `COMPOSE_FILE`:
|
||||
```powershell
|
||||
$composeFile = 'docker-compose.yml:docker-compose.saas.yml'
|
||||
if ($c.TlsMode -eq 'custom') { $composeFile += ':docker-compose.tls.yml' }
|
||||
if ($c.MonitoringNetwork) { $composeFile += ':docker-compose.monitoring.yml' }
|
||||
```
|
||||
|
||||
And append to `$content`:
|
||||
```powershell
|
||||
$content += "`n`n# Compose file assembly`nCOMPOSE_FILE=$composeFile"
|
||||
if ($c.MonitoringNetwork) {
|
||||
$content += "`n`n# Monitoring`nMONITORING_NETWORK=$($c.MonitoringNetwork)"
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `Main` — replace `Generate-ComposeFile` call with `Copy-Templates`**
|
||||
|
||||
At line 1798, change:
|
||||
```powershell
|
||||
Generate-ComposeFile
|
||||
```
|
||||
to:
|
||||
```powershell
|
||||
Copy-Templates
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `Handle-Rerun` upgrade path**
|
||||
|
||||
At line 1716, change:
|
||||
```powershell
|
||||
Generate-ComposeFile
|
||||
```
|
||||
to:
|
||||
```powershell
|
||||
Copy-Templates
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update reinstall cleanup to remove template files**
|
||||
|
||||
At line 1746, update the filename list:
|
||||
```powershell
|
||||
foreach ($fname in @('.env','.env.bak','.env.example','docker-compose.yml','docker-compose.saas.yml','docker-compose.server.yml','docker-compose.tls.yml','docker-compose.monitoring.yml','traefik-dynamic.yml','cameleer.conf','credentials.txt','INSTALL.md')) {
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add installer/install.ps1
|
||||
git commit -m "refactor(installer): replace ps1 compose generation with template copying"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Update existing generated install and clean up
|
||||
|
||||
**Files:**
|
||||
- Modify: `installer/cameleer/docker-compose.yml` (replace with template copy for dev environment)
|
||||
|
||||
- [ ] **Step 1: Remove the old generated docker-compose.yml from the cameleer/ directory**
|
||||
|
||||
The `installer/cameleer/` directory contains a previously generated install. The `docker-compose.yml` there is now stale — it was generated by the old inline method. Since this is a dev environment output, remove it (it will be recreated by running the installer with the new template approach).
|
||||
|
||||
```bash
|
||||
git rm installer/cameleer/docker-compose.yml
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `installer/cameleer/` to `.gitignore` if not already there**
|
||||
|
||||
The install output directory should not be tracked. Check if `.gitignore` already covers it. If not, add:
|
||||
|
||||
```
|
||||
installer/cameleer/
|
||||
```
|
||||
|
||||
This prevents generated `.env`, `credentials.txt`, and compose files from being committed.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add -A installer/cameleer/ .gitignore
|
||||
git commit -m "chore(installer): remove generated install output, add to gitignore"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Verify the templates produce equivalent output
|
||||
|
||||
**Files:** (no changes — verification only)
|
||||
|
||||
- [ ] **Step 1: Compare template output against the old generated compose**
|
||||
|
||||
Create a temporary `.env` file and run `docker compose config` to render the resolved compose. Compare against the old generated output:
|
||||
|
||||
```bash
|
||||
cd installer/cameleer
|
||||
# Back up old generated file for comparison
|
||||
cp docker-compose.yml docker-compose.old.yml 2>/dev/null || true
|
||||
|
||||
# Create a test .env that exercises the SaaS path
|
||||
cat > /tmp/test-saas.env << 'EOF'
|
||||
COMPOSE_FILE=docker-compose.yml:docker-compose.saas.yml
|
||||
VERSION=latest
|
||||
PUBLIC_HOST=test.example.com
|
||||
PUBLIC_PROTOCOL=https
|
||||
HTTP_PORT=80
|
||||
HTTPS_PORT=443
|
||||
LOGTO_CONSOLE_PORT=3002
|
||||
LOGTO_CONSOLE_BIND=0.0.0.0
|
||||
POSTGRES_USER=cameleer
|
||||
POSTGRES_PASSWORD=testpass
|
||||
POSTGRES_DB=cameleer_saas
|
||||
CLICKHOUSE_PASSWORD=testpass
|
||||
SAAS_ADMIN_USER=admin
|
||||
SAAS_ADMIN_PASS=testpass
|
||||
NODE_TLS_REJECT=0
|
||||
DOCKER_SOCKET=/var/run/docker.sock
|
||||
DOCKER_GID=0
|
||||
CAMELEER_SAAS_PROVISIONING_SERVERIMAGE=gitea.siegeln.net/cameleer/cameleer-server:latest
|
||||
CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE=gitea.siegeln.net/cameleer/cameleer-server-ui:latest
|
||||
EOF
|
||||
|
||||
# Render the new templates
|
||||
cd ../templates
|
||||
docker compose --env-file /tmp/test-saas.env config
|
||||
```
|
||||
|
||||
Expected: A fully resolved compose with all 5 services (traefik, postgres, clickhouse, logto, saas), correct environment variables, and the monitoring noop network.
|
||||
|
||||
- [ ] **Step 2: Test standalone mode rendering**
|
||||
|
||||
```bash
|
||||
cat > /tmp/test-standalone.env << 'EOF'
|
||||
COMPOSE_FILE=docker-compose.yml:docker-compose.server.yml
|
||||
VERSION=latest
|
||||
PUBLIC_HOST=test.example.com
|
||||
PUBLIC_PROTOCOL=https
|
||||
HTTP_PORT=80
|
||||
HTTPS_PORT=443
|
||||
POSTGRES_IMAGE=postgres:16-alpine
|
||||
POSTGRES_USER=cameleer
|
||||
POSTGRES_PASSWORD=testpass
|
||||
POSTGRES_DB=cameleer
|
||||
CLICKHOUSE_PASSWORD=testpass
|
||||
SERVER_ADMIN_USER=admin
|
||||
SERVER_ADMIN_PASS=testpass
|
||||
BOOTSTRAP_TOKEN=testtoken
|
||||
DOCKER_SOCKET=/var/run/docker.sock
|
||||
DOCKER_GID=0
|
||||
EOF
|
||||
|
||||
cd ../templates
|
||||
docker compose --env-file /tmp/test-standalone.env config
|
||||
```
|
||||
|
||||
Expected: 5 services (traefik, postgres with `postgres:16-alpine` image, clickhouse, server, server-ui). Postgres `POSTGRES_DB` should be `cameleer`. Server should have all env vars resolved.
|
||||
|
||||
- [ ] **Step 3: Test with TLS + monitoring overlays**
|
||||
|
||||
```bash
|
||||
cat > /tmp/test-full.env << 'EOF'
|
||||
COMPOSE_FILE=docker-compose.yml:docker-compose.saas.yml:docker-compose.tls.yml:docker-compose.monitoring.yml
|
||||
VERSION=latest
|
||||
PUBLIC_HOST=test.example.com
|
||||
PUBLIC_PROTOCOL=https
|
||||
HTTP_PORT=80
|
||||
HTTPS_PORT=443
|
||||
LOGTO_CONSOLE_PORT=3002
|
||||
LOGTO_CONSOLE_BIND=0.0.0.0
|
||||
POSTGRES_USER=cameleer
|
||||
POSTGRES_PASSWORD=testpass
|
||||
POSTGRES_DB=cameleer_saas
|
||||
CLICKHOUSE_PASSWORD=testpass
|
||||
SAAS_ADMIN_USER=admin
|
||||
SAAS_ADMIN_PASS=testpass
|
||||
NODE_TLS_REJECT=0
|
||||
DOCKER_SOCKET=/var/run/docker.sock
|
||||
DOCKER_GID=0
|
||||
MONITORING_NETWORK=prometheus
|
||||
CAMELEER_SAAS_PROVISIONING_SERVERIMAGE=gitea.siegeln.net/cameleer/cameleer-server:latest
|
||||
CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE=gitea.siegeln.net/cameleer/cameleer-server-ui:latest
|
||||
EOF
|
||||
|
||||
cd ../templates
|
||||
docker compose --env-file /tmp/test-full.env config
|
||||
```
|
||||
|
||||
Expected: Same as SaaS mode but with `./certs:/user-certs:ro` volume on traefik and the `monitoring` network declared as `external: true` with name `prometheus`.
|
||||
|
||||
- [ ] **Step 4: Clean up temp files**
|
||||
|
||||
```bash
|
||||
rm -f /tmp/test-saas.env /tmp/test-standalone.env /tmp/test-full.env
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit verification results as a note (optional)**
|
||||
|
||||
No code changes — this task is verification only. If all checks pass, proceed to the final commit.
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Final commit — update CLAUDE.md deployment modes table
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md` (update Deployment Modes section to reference template files)
|
||||
|
||||
- [ ] **Step 1: Update the deployment modes documentation**
|
||||
|
||||
In the "Deployment Modes (installer)" section of CLAUDE.md, add a note about the template-based approach:
|
||||
|
||||
After the deployment modes table, add:
|
||||
|
||||
```markdown
|
||||
The installer uses static docker-compose templates in `installer/templates/`. Templates are copied to the install directory and composed via `COMPOSE_FILE` in `.env`:
|
||||
- `docker-compose.yml` — shared infrastructure (traefik, postgres, clickhouse)
|
||||
- `docker-compose.saas.yml` — SaaS mode (logto, cameleer-saas)
|
||||
- `docker-compose.server.yml` — standalone mode (server, server-ui)
|
||||
- `docker-compose.tls.yml` — overlay: custom TLS cert volume
|
||||
- `docker-compose.monitoring.yml` — overlay: external monitoring network
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "docs: update CLAUDE.md with template-based installer architecture"
|
||||
```
|
||||
@@ -0,0 +1,464 @@
|
||||
# Per-Tenant PostgreSQL Isolation Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Give each tenant its own PostgreSQL user and schema so tenant servers can only access their own data at the database level.
|
||||
|
||||
**Architecture:** During provisioning, create a dedicated PG user (`tenant_<slug>`) with a matching schema. Pass per-tenant credentials and `currentSchema`/`ApplicationName` JDBC parameters to the server container. On delete, drop both schema and user. Existing tenants without `dbPassword` fall back to shared credentials for backwards compatibility.
|
||||
|
||||
**Tech Stack:** Java 21, Spring Boot 3.4, Flyway, PostgreSQL 16, Docker Java API
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-15-per-tenant-pg-isolation-design.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Flyway Migration — add `db_password` column
|
||||
|
||||
**Files:**
|
||||
- Create: `src/main/resources/db/migration/V015__add_tenant_db_password.sql`
|
||||
|
||||
- [ ] **Step 1: Create migration file**
|
||||
|
||||
```sql
|
||||
ALTER TABLE tenants ADD COLUMN db_password VARCHAR(255);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify migration applies**
|
||||
|
||||
Run: `mvn flyway:info -pl .` or start the app and check logs for `V015__add_tenant_db_password` in Flyway output.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/resources/db/migration/V015__add_tenant_db_password.sql
|
||||
git commit -m "feat: add db_password column to tenants table (V015)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: TenantEntity — add `dbPassword` field
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/tenant/TenantEntity.java`
|
||||
|
||||
- [ ] **Step 1: Add field and accessors**
|
||||
|
||||
After the `provisionError` field (line 59), add:
|
||||
|
||||
```java
|
||||
@Column(name = "db_password")
|
||||
private String dbPassword;
|
||||
```
|
||||
|
||||
After the `setProvisionError` method (line 102), add:
|
||||
|
||||
```java
|
||||
public String getDbPassword() { return dbPassword; }
|
||||
public void setDbPassword(String dbPassword) { this.dbPassword = dbPassword; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/java/net/siegeln/cameleer/saas/tenant/TenantEntity.java
|
||||
git commit -m "feat: add dbPassword field to TenantEntity"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Create `TenantDatabaseService`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/main/java/net/siegeln/cameleer/saas/provisioning/TenantDatabaseService.java`
|
||||
|
||||
- [ ] **Step 1: Implement the service**
|
||||
|
||||
```java
|
||||
package net.siegeln.cameleer.saas.provisioning;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
|
||||
/**
|
||||
* Creates and drops per-tenant PostgreSQL users and schemas
|
||||
* on the shared cameleer database for DB-level tenant isolation.
|
||||
*/
|
||||
@Service
|
||||
public class TenantDatabaseService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TenantDatabaseService.class);
|
||||
|
||||
private final ProvisioningProperties props;
|
||||
|
||||
public TenantDatabaseService(ProvisioningProperties props) {
|
||||
this.props = props;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a dedicated PG user and schema for a tenant.
|
||||
* Idempotent — skips if user/schema already exist.
|
||||
*/
|
||||
public void createTenantDatabase(String slug, String password) {
|
||||
validateSlug(slug);
|
||||
|
||||
String url = props.datasourceUrl();
|
||||
if (url == null || url.isBlank()) {
|
||||
log.warn("No datasource URL configured — skipping tenant DB setup");
|
||||
return;
|
||||
}
|
||||
|
||||
String user = "tenant_" + slug;
|
||||
String schema = "tenant_" + slug;
|
||||
|
||||
try (Connection conn = DriverManager.getConnection(url, props.datasourceUsername(), props.datasourcePassword());
|
||||
Statement stmt = conn.createStatement()) {
|
||||
|
||||
// Create user if not exists
|
||||
boolean userExists;
|
||||
try (ResultSet rs = stmt.executeQuery(
|
||||
"SELECT 1 FROM pg_roles WHERE rolname = '" + user + "'")) {
|
||||
userExists = rs.next();
|
||||
}
|
||||
if (!userExists) {
|
||||
stmt.execute("CREATE USER \"" + user + "\" WITH PASSWORD '" + escapePassword(password) + "'");
|
||||
log.info("Created PostgreSQL user: {}", user);
|
||||
} else {
|
||||
// Update password on re-provision
|
||||
stmt.execute("ALTER USER \"" + user + "\" WITH PASSWORD '" + escapePassword(password) + "'");
|
||||
log.info("Updated password for existing PostgreSQL user: {}", user);
|
||||
}
|
||||
|
||||
// Create schema if not exists
|
||||
boolean schemaExists;
|
||||
try (ResultSet rs = stmt.executeQuery(
|
||||
"SELECT 1 FROM information_schema.schemata WHERE schema_name = '" + schema + "'")) {
|
||||
schemaExists = rs.next();
|
||||
}
|
||||
if (!schemaExists) {
|
||||
stmt.execute("CREATE SCHEMA \"" + schema + "\" AUTHORIZATION \"" + user + "\"");
|
||||
log.info("Created PostgreSQL schema: {}", schema);
|
||||
} else {
|
||||
// Ensure ownership is correct
|
||||
stmt.execute("ALTER SCHEMA \"" + schema + "\" OWNER TO \"" + user + "\"");
|
||||
log.info("Schema {} already exists — ensured ownership", schema);
|
||||
}
|
||||
|
||||
// Revoke access to public schema
|
||||
stmt.execute("REVOKE ALL ON SCHEMA public FROM \"" + user + "\"");
|
||||
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to create tenant database for '" + slug + "': " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop tenant schema (CASCADE) and user. Idempotent.
|
||||
*/
|
||||
public void dropTenantDatabase(String slug) {
|
||||
validateSlug(slug);
|
||||
|
||||
String url = props.datasourceUrl();
|
||||
if (url == null || url.isBlank()) {
|
||||
log.warn("No datasource URL configured — skipping tenant DB cleanup");
|
||||
return;
|
||||
}
|
||||
|
||||
String user = "tenant_" + slug;
|
||||
String schema = "tenant_" + slug;
|
||||
|
||||
try (Connection conn = DriverManager.getConnection(url, props.datasourceUsername(), props.datasourcePassword());
|
||||
Statement stmt = conn.createStatement()) {
|
||||
stmt.execute("DROP SCHEMA IF EXISTS \"" + schema + "\" CASCADE");
|
||||
log.info("Dropped PostgreSQL schema: {}", schema);
|
||||
|
||||
stmt.execute("DROP USER IF EXISTS \"" + user + "\"");
|
||||
log.info("Dropped PostgreSQL user: {}", user);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to drop tenant database for '{}': {}", slug, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void validateSlug(String slug) {
|
||||
if (slug == null || !slug.matches("^[a-z0-9-]+$")) {
|
||||
throw new IllegalArgumentException("Invalid tenant slug: " + slug);
|
||||
}
|
||||
}
|
||||
|
||||
private String escapePassword(String password) {
|
||||
return password.replace("'", "''");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/java/net/siegeln/cameleer/saas/provisioning/TenantDatabaseService.java
|
||||
git commit -m "feat: add TenantDatabaseService for per-tenant PG user+schema"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add `dbPassword` to `TenantProvisionRequest`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/provisioning/TenantProvisionRequest.java`
|
||||
|
||||
- [ ] **Step 1: Add field to record**
|
||||
|
||||
Replace the entire record with:
|
||||
|
||||
```java
|
||||
package net.siegeln.cameleer.saas.provisioning;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record TenantProvisionRequest(
|
||||
UUID tenantId,
|
||||
String slug,
|
||||
String tier,
|
||||
String licenseToken,
|
||||
String dbPassword
|
||||
) {}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/java/net/siegeln/cameleer/saas/provisioning/TenantProvisionRequest.java
|
||||
git commit -m "feat: add dbPassword to TenantProvisionRequest"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Update `DockerTenantProvisioner` — per-tenant JDBC URL
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java:197-200`
|
||||
|
||||
- [ ] **Step 1: Replace shared credentials with per-tenant credentials**
|
||||
|
||||
In `createServerContainer()` (line 197-200), replace:
|
||||
|
||||
```java
|
||||
var env = new java.util.ArrayList<>(List.of(
|
||||
"SPRING_DATASOURCE_URL=" + props.datasourceUrl(),
|
||||
"SPRING_DATASOURCE_USERNAME=" + props.datasourceUsername(),
|
||||
"SPRING_DATASOURCE_PASSWORD=" + props.datasourcePassword(),
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```java
|
||||
// Per-tenant DB isolation: dedicated user+schema when dbPassword is set,
|
||||
// shared credentials for backwards compatibility with pre-isolation tenants.
|
||||
String dsUrl;
|
||||
String dsUser;
|
||||
String dsPass;
|
||||
if (req.dbPassword() != null) {
|
||||
dsUrl = props.datasourceUrl() + "?currentSchema=tenant_" + slug + "&ApplicationName=tenant_" + slug;
|
||||
dsUser = "tenant_" + slug;
|
||||
dsPass = req.dbPassword();
|
||||
} else {
|
||||
dsUrl = props.datasourceUrl();
|
||||
dsUser = props.datasourceUsername();
|
||||
dsPass = props.datasourcePassword();
|
||||
}
|
||||
var env = new java.util.ArrayList<>(List.of(
|
||||
"SPRING_DATASOURCE_URL=" + dsUrl,
|
||||
"SPRING_DATASOURCE_USERNAME=" + dsUser,
|
||||
"SPRING_DATASOURCE_PASSWORD=" + dsPass,
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java
|
||||
git commit -m "feat: construct per-tenant JDBC URL with currentSchema and ApplicationName"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Update `VendorTenantService` — provisioning and delete flows
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java`
|
||||
|
||||
- [ ] **Step 1: Inject `TenantDatabaseService`**
|
||||
|
||||
Add to the constructor and field declarations:
|
||||
|
||||
```java
|
||||
private final TenantDatabaseService tenantDatabaseService;
|
||||
```
|
||||
|
||||
Add to the constructor parameter list and assignment. (Follow the existing pattern of other injected services.)
|
||||
|
||||
- [ ] **Step 2: Update `provisionAsync()` — create DB before containers**
|
||||
|
||||
In `provisionAsync()` (around line 120), add DB creation before the provision call. Replace:
|
||||
|
||||
```java
|
||||
var provisionRequest = new TenantProvisionRequest(tenantId, slug, tier, licenseToken);
|
||||
ProvisionResult result = tenantProvisioner.provision(provisionRequest);
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```java
|
||||
// Create per-tenant PG user + schema
|
||||
String dbPassword = UUID.randomUUID().toString().replace("-", "")
|
||||
+ UUID.randomUUID().toString().replace("-", "").substring(0, 8);
|
||||
try {
|
||||
tenantDatabaseService.createTenantDatabase(slug, dbPassword);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to create tenant database for {}: {}", slug, e.getMessage(), e);
|
||||
tenantRepository.findById(tenantId).ifPresent(t -> {
|
||||
t.setProvisionError("Database setup failed: " + e.getMessage());
|
||||
tenantRepository.save(t);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Store DB password on entity
|
||||
TenantEntity tenantForDb = tenantRepository.findById(tenantId).orElse(null);
|
||||
if (tenantForDb == null) {
|
||||
log.error("Tenant {} disappeared during provisioning", slug);
|
||||
return;
|
||||
}
|
||||
tenantForDb.setDbPassword(dbPassword);
|
||||
tenantRepository.save(tenantForDb);
|
||||
|
||||
var provisionRequest = new TenantProvisionRequest(tenantId, slug, tier, licenseToken, dbPassword);
|
||||
ProvisionResult result = tenantProvisioner.provision(provisionRequest);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update the existing `TenantProvisionRequest` constructor call in upgrade flow**
|
||||
|
||||
Search for any other `new TenantProvisionRequest(...)` calls. The `upgradeServer` method (or re-provision after upgrade) also creates a provision request. Update it to pass `dbPassword` from the entity:
|
||||
|
||||
```java
|
||||
TenantEntity tenant = ...;
|
||||
var provisionRequest = new TenantProvisionRequest(
|
||||
tenant.getId(), tenant.getSlug(), tenant.getTier().name(),
|
||||
licenseToken, tenant.getDbPassword());
|
||||
```
|
||||
|
||||
If the tenant has `dbPassword == null` (pre-existing), this is fine — Task 5 handles the null fallback.
|
||||
|
||||
- [ ] **Step 4: Update `delete()` — use TenantDatabaseService**
|
||||
|
||||
In `delete()` (around line 306), replace:
|
||||
|
||||
```java
|
||||
// Erase tenant data from server databases (GDPR)
|
||||
dataCleanupService.cleanup(tenant.getSlug());
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```java
|
||||
// Drop per-tenant PG schema + user
|
||||
tenantDatabaseService.dropTenantDatabase(tenant.getSlug());
|
||||
|
||||
// Erase ClickHouse data (GDPR)
|
||||
dataCleanupService.cleanupClickHouse(tenant.getSlug());
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java
|
||||
git commit -m "feat: create per-tenant PG database during provisioning, drop on delete"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Refactor `TenantDataCleanupService` — ClickHouse only
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/provisioning/TenantDataCleanupService.java`
|
||||
|
||||
- [ ] **Step 1: Remove PG logic, rename public method**
|
||||
|
||||
Remove the `dropPostgresSchema()` method and the `cleanup()` method. Replace with a single public method:
|
||||
|
||||
```java
|
||||
/**
|
||||
* Deletes tenant data from ClickHouse tables (GDPR data erasure).
|
||||
* PostgreSQL cleanup is handled by TenantDatabaseService.
|
||||
*/
|
||||
public void cleanupClickHouse(String slug) {
|
||||
deleteClickHouseData(slug);
|
||||
}
|
||||
```
|
||||
|
||||
Remove the `dropPostgresSchema()` private method entirely. Keep `deleteClickHouseData()` unchanged.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/java/net/siegeln/cameleer/saas/provisioning/TenantDataCleanupService.java
|
||||
git commit -m "refactor: move PG cleanup to TenantDatabaseService, keep only ClickHouse"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Verify end-to-end
|
||||
|
||||
- [ ] **Step 1: Build**
|
||||
|
||||
```bash
|
||||
mvn compile -pl .
|
||||
```
|
||||
|
||||
Verify no compilation errors.
|
||||
|
||||
- [ ] **Step 2: Deploy and test tenant creation**
|
||||
|
||||
Deploy the updated SaaS image. Create a new tenant via the UI. Verify in PostgreSQL:
|
||||
|
||||
```sql
|
||||
-- Should show the new tenant user
|
||||
SELECT rolname FROM pg_roles WHERE rolname LIKE 'tenant_%';
|
||||
|
||||
-- Should show the new tenant schema
|
||||
SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant_%';
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify server container env vars**
|
||||
|
||||
```bash
|
||||
docker inspect cameleer-server-<slug> | grep -E "DATASOURCE|currentSchema|ApplicationName"
|
||||
```
|
||||
|
||||
Expected: URL contains `?currentSchema=tenant_<slug>&ApplicationName=tenant_<slug>`, username is `tenant_<slug>`.
|
||||
|
||||
- [ ] **Step 4: Verify Infrastructure page**
|
||||
|
||||
Navigate to Vendor > Infrastructure. The PostgreSQL card should now show the tenant schema with size/tables/rows.
|
||||
|
||||
- [ ] **Step 5: Test tenant deletion**
|
||||
|
||||
Delete the tenant. Verify:
|
||||
|
||||
```sql
|
||||
-- User should be gone
|
||||
SELECT rolname FROM pg_roles WHERE rolname LIKE 'tenant_%';
|
||||
|
||||
-- Schema should be gone
|
||||
SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant_%';
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit all remaining changes**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat: per-tenant PostgreSQL isolation — complete implementation"
|
||||
```
|
||||
1371
docs/superpowers/plans/2026-04-25-email-connector-ui-plan.md
Normal file
449
docs/superpowers/plans/2026-04-26-email-template-polish-plan.md
Normal file
@@ -0,0 +1,449 @@
|
||||
# Email Template Polish Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace inline HTML email templates with polished, branded HTML files loaded from classpath, featuring playful desert/caravan copy, structured card layout with watermark, and proper header/footer.
|
||||
|
||||
**Architecture:** Extract 4 email templates from `EmailConnectorService.buildSmtpConfig()` into standalone HTML files at `src/main/resources/email-templates/`. Generate a pre-faded watermark PNG served as a static asset. Inject `ProvisioningProperties` to resolve the watermark URL at runtime.
|
||||
|
||||
**Tech Stack:** Java 21, Spring Boot, ImageMagick (one-time asset generation), HTML email (inline styles only)
|
||||
|
||||
---
|
||||
|
||||
### File Map
|
||||
|
||||
| Action | File | Purpose |
|
||||
|--------|------|---------|
|
||||
| Create | `src/main/resources/email-templates/register.html` | Registration verification email |
|
||||
| Create | `src/main/resources/email-templates/sign-in.html` | Sign-in verification email |
|
||||
| Create | `src/main/resources/email-templates/forgot-password.html` | Password reset email |
|
||||
| Create | `src/main/resources/email-templates/generic.html` | Generic verification email |
|
||||
| Create | `src/main/resources/static/assets/email-watermark.png` | Pre-faded logo at 7% opacity |
|
||||
| Modify | `src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java` | Load templates from classpath, inject watermark URL |
|
||||
| Modify | `src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java:49` | Permit `/assets/**` for unauthenticated email clients |
|
||||
| Create | `src/test/java/net/siegeln/cameleer/saas/vendor/EmailTemplateLoadingTest.java` | Verify templates load and placeholders resolve |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Generate the pre-faded watermark PNG
|
||||
|
||||
**Files:**
|
||||
- Create: `src/main/resources/static/assets/email-watermark.png`
|
||||
|
||||
- [ ] **Step 1: Generate the faded watermark using ImageMagick**
|
||||
|
||||
Source the logo from the design-system sibling repo. Apply 7% opacity on a transparent background, output to the static assets directory:
|
||||
|
||||
```bash
|
||||
magick "C:/Users/Hendrik/Documents/projects/design-system/assets/cameleer-logo.png" \
|
||||
-channel A -evaluate Multiply 0.07 +channel \
|
||||
-resize 320x320 \
|
||||
"src/main/resources/static/assets/email-watermark.png"
|
||||
```
|
||||
|
||||
If `magick` is not available, use Python Pillow as fallback:
|
||||
|
||||
```bash
|
||||
python3 -c "
|
||||
from PIL import Image
|
||||
img = Image.open('C:/Users/Hendrik/Documents/projects/design-system/assets/cameleer-logo.png').convert('RGBA')
|
||||
img = img.resize((320, 320), Image.LANCZOS)
|
||||
r, g, b, a = img.split()
|
||||
a = a.point(lambda x: int(x * 0.07))
|
||||
img = Image.merge('RGBA', (r, g, b, a))
|
||||
img.save('src/main/resources/static/assets/email-watermark.png')
|
||||
print('Saved watermark')
|
||||
"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the file exists and is reasonable size**
|
||||
|
||||
```bash
|
||||
ls -la src/main/resources/static/assets/email-watermark.png
|
||||
```
|
||||
|
||||
Expected: File exists, roughly 5-30 KB.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/resources/static/assets/email-watermark.png
|
||||
git commit -m "feat: add pre-faded logo watermark for email templates"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Permit static assets in SecurityConfig
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java:49`
|
||||
|
||||
The watermark image must be loadable by email clients without authentication. The current security config has `.anyRequest().authenticated()` as catch-all, so `/assets/**` needs an explicit permit.
|
||||
|
||||
- [ ] **Step 1: Add `/assets/**` to the permitAll list**
|
||||
|
||||
In `SecurityConfig.java`, find the existing line:
|
||||
|
||||
```java
|
||||
.requestMatchers("/_app/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll()
|
||||
```
|
||||
|
||||
Change it to:
|
||||
|
||||
```java
|
||||
.requestMatchers("/_app/**", "/assets/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java
|
||||
git commit -m "feat: permit /assets/** for unauthenticated access (email watermark)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Create the 4 HTML email template files
|
||||
|
||||
**Files:**
|
||||
- Create: `src/main/resources/email-templates/register.html`
|
||||
- Create: `src/main/resources/email-templates/sign-in.html`
|
||||
- Create: `src/main/resources/email-templates/forgot-password.html`
|
||||
- Create: `src/main/resources/email-templates/generic.html`
|
||||
|
||||
All templates use the same card structure. The `{{code}}` placeholder is Logto's built-in substitution. The `{{watermarkUrl}}` placeholder is replaced by `EmailConnectorService` at runtime.
|
||||
|
||||
- [ ] **Step 1: Create `register.html`**
|
||||
|
||||
```html
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
|
||||
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
|
||||
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
|
||||
</div>
|
||||
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
|
||||
<img src="{{watermarkUrl}}" width="320" height="320" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;" alt="" />
|
||||
<div style="position:relative;">
|
||||
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Welcome to the caravan!</p>
|
||||
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">Enter this code to verify your email and claim your spot. The dunes wait for no one.</p>
|
||||
<div style="text-align:center;margin:0 0 24px;">
|
||||
<div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
|
||||
<span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">This code expires in 10 minutes. If you didn't request this, you can safely ignore this email — no camels were harmed.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
|
||||
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
|
||||
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `sign-in.html`**
|
||||
|
||||
```html
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
|
||||
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
|
||||
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
|
||||
</div>
|
||||
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
|
||||
<img src="{{watermarkUrl}}" width="320" height="320" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;" alt="" />
|
||||
<div style="position:relative;">
|
||||
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Back at the oasis already?</p>
|
||||
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">Here's your sign-in code. The caravan master is checking credentials.</p>
|
||||
<div style="text-align:center;margin:0 0 24px;">
|
||||
<div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
|
||||
<span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">This code expires in 10 minutes.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
|
||||
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
|
||||
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create `forgot-password.html`**
|
||||
|
||||
```html
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
|
||||
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
|
||||
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
|
||||
</div>
|
||||
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
|
||||
<img src="{{watermarkUrl}}" width="320" height="320" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;" alt="" />
|
||||
<div style="position:relative;">
|
||||
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Lost in the dunes?</p>
|
||||
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">No worries — enter this code to reset your password and get back on the trail.</p>
|
||||
<div style="text-align:center;margin:0 0 24px;">
|
||||
<div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
|
||||
<span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">This code expires in 10 minutes. If you didn't request a password reset, you can safely ignore this email.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
|
||||
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
|
||||
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create `generic.html`**
|
||||
|
||||
```html
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
|
||||
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
|
||||
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
|
||||
</div>
|
||||
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
|
||||
<img src="{{watermarkUrl}}" width="320" height="320" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;" alt="" />
|
||||
<div style="position:relative;">
|
||||
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Quick checkpoint</p>
|
||||
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">Here's your verification code. Just making sure it's really you at the reins.</p>
|
||||
<div style="text-align:center;margin:0 0 24px;">
|
||||
<div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
|
||||
<span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">This code expires in 10 minutes.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
|
||||
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
|
||||
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/resources/email-templates/
|
||||
git commit -m "feat: add branded HTML email templates with desert/caravan copy"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Refactor EmailConnectorService to load templates from classpath
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/test/java/net/siegeln/cameleer/saas/vendor/EmailTemplateLoadingTest.java`:
|
||||
|
||||
```java
|
||||
package net.siegeln.cameleer.saas.vendor;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class EmailTemplateLoadingTest {
|
||||
|
||||
private static final String[] TEMPLATE_FILES = {
|
||||
"email-templates/register.html",
|
||||
"email-templates/sign-in.html",
|
||||
"email-templates/forgot-password.html",
|
||||
"email-templates/generic.html"
|
||||
};
|
||||
|
||||
@Test
|
||||
void allTemplateFilesExistOnClasspath() {
|
||||
for (String path : TEMPLATE_FILES) {
|
||||
var resource = new ClassPathResource(path);
|
||||
assertTrue(resource.exists(), "Template file missing: " + path);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void templatesContainCodePlaceholder() throws IOException {
|
||||
for (String path : TEMPLATE_FILES) {
|
||||
String content = new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
|
||||
assertTrue(content.contains("{{code}}"),
|
||||
path + " must contain {{code}} placeholder");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void templatesContainWatermarkPlaceholder() throws IOException {
|
||||
for (String path : TEMPLATE_FILES) {
|
||||
String content = new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
|
||||
assertTrue(content.contains("{{watermarkUrl}}"),
|
||||
path + " must contain {{watermarkUrl}} placeholder");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void watermarkPlaceholderIsReplaced() throws IOException {
|
||||
String content = new ClassPathResource("email-templates/register.html")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
String resolved = content.replace("{{watermarkUrl}}",
|
||||
"https://example.com/platform/assets/email-watermark.png");
|
||||
assertFalse(resolved.contains("{{watermarkUrl}}"));
|
||||
assertTrue(resolved.contains("https://example.com/platform/assets/email-watermark.png"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void templatesContainBrandElements() throws IOException {
|
||||
for (String path : TEMPLATE_FILES) {
|
||||
String content = new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
|
||||
assertTrue(content.contains("Cameleer.io"),
|
||||
path + " must contain Cameleer.io header");
|
||||
assertTrue(content.contains("Apache Camel observability"),
|
||||
path + " must contain tagline");
|
||||
assertTrue(content.contains("#C6820E"),
|
||||
path + " must use brand color");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they pass (templates exist from Task 3)**
|
||||
|
||||
```bash
|
||||
./mvnw test -pl . -Dtest=EmailTemplateLoadingTest -Dspring.profiles.active=test
|
||||
```
|
||||
|
||||
Expected: All 5 tests PASS.
|
||||
|
||||
- [ ] **Step 3: Add `ProvisioningProperties` dependency to `EmailConnectorService`**
|
||||
|
||||
Replace the constructor and add the template loading logic. The full updated `EmailConnectorService.java`:
|
||||
|
||||
Change the imports and fields at the top of the class — add `ProvisioningProperties` import and field:
|
||||
|
||||
```java
|
||||
import net.siegeln.cameleer.saas.provisioning.ProvisioningProperties;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
```
|
||||
|
||||
Replace the constructor:
|
||||
|
||||
```java
|
||||
private final LogtoManagementClient logtoClient;
|
||||
private final ProvisioningProperties provisioningProps;
|
||||
|
||||
public EmailConnectorService(LogtoManagementClient logtoClient, ProvisioningProperties provisioningProps) {
|
||||
this.logtoClient = logtoClient;
|
||||
this.provisioningProps = provisioningProps;
|
||||
}
|
||||
```
|
||||
|
||||
Replace the `buildSmtpConfig` method (lines 157-191) with:
|
||||
|
||||
```java
|
||||
/** Load an email template from classpath and resolve the watermark URL placeholder. */
|
||||
private String loadTemplate(String filename) {
|
||||
try {
|
||||
String content = new ClassPathResource("email-templates/" + filename)
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
String watermarkUrl = provisioningProps.publicProtocol() + "://"
|
||||
+ provisioningProps.publicHost() + "/platform/assets/email-watermark.png";
|
||||
return content.replace("{{watermarkUrl}}", watermarkUrl);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Failed to load email template: " + filename, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the Logto SMTP connector config with Cameleer-branded email templates. */
|
||||
private Map<String, Object> buildSmtpConfig(SmtpConfig smtp) {
|
||||
var config = new HashMap<String, Object>();
|
||||
config.put("host", smtp.host());
|
||||
config.put("port", smtp.port());
|
||||
config.put("auth", Map.of("user", smtp.username(), "pass", smtp.password()));
|
||||
config.put("fromEmail", smtp.fromEmail());
|
||||
config.put("templates", List.of(
|
||||
Map.of(
|
||||
"usageType", "Register",
|
||||
"contentType", "text/html",
|
||||
"subject", "Your caravan pass is almost ready",
|
||||
"content", loadTemplate("register.html")
|
||||
),
|
||||
Map.of(
|
||||
"usageType", "SignIn",
|
||||
"contentType", "text/html",
|
||||
"subject", "Your Cameleer sign-in code",
|
||||
"content", loadTemplate("sign-in.html")
|
||||
),
|
||||
Map.of(
|
||||
"usageType", "ForgotPassword",
|
||||
"contentType", "text/html",
|
||||
"subject", "Reset your Cameleer password",
|
||||
"content", loadTemplate("forgot-password.html")
|
||||
),
|
||||
Map.of(
|
||||
"usageType", "Generic",
|
||||
"contentType", "text/html",
|
||||
"subject", "Your Cameleer verification code",
|
||||
"content", loadTemplate("generic.html")
|
||||
)
|
||||
));
|
||||
return config;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify the project compiles**
|
||||
|
||||
```bash
|
||||
./mvnw compile -pl .
|
||||
```
|
||||
|
||||
Expected: BUILD SUCCESS
|
||||
|
||||
- [ ] **Step 5: Run the template tests again to confirm nothing broke**
|
||||
|
||||
```bash
|
||||
./mvnw test -pl . -Dtest=EmailTemplateLoadingTest -Dspring.profiles.active=test
|
||||
```
|
||||
|
||||
Expected: All 5 tests PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java
|
||||
git add src/test/java/net/siegeln/cameleer/saas/vendor/EmailTemplateLoadingTest.java
|
||||
git commit -m "feat: load email templates from classpath with watermark URL resolution"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Run the full test suite
|
||||
|
||||
**Files:** None (verification only)
|
||||
|
||||
- [ ] **Step 1: Run all tests**
|
||||
|
||||
```bash
|
||||
./mvnw test -Dspring.profiles.active=test
|
||||
```
|
||||
|
||||
Expected: BUILD SUCCESS, all tests pass. If any existing tests fail due to the new `ProvisioningProperties` constructor parameter on `EmailConnectorService`, they will need their mocks updated — but there are no existing tests for this class.
|
||||
|
||||
- [ ] **Step 2: Verify the watermark is accessible without auth by checking SecurityConfig**
|
||||
|
||||
Confirm the `/assets/**` matcher is in the `permitAll()` chain (done in Task 2). With context-path `/platform`, the full public URL will be `https://<host>/platform/assets/email-watermark.png`.
|
||||
|
||||
- [ ] **Step 3: Final commit if any fixes were needed**
|
||||
|
||||
Only if test failures required changes:
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: resolve test failures from email template refactor"
|
||||
```
|
||||
614
docs/superpowers/plans/2026-04-26-license-minter-integration.md
Normal file
@@ -0,0 +1,614 @@
|
||||
# License Minter Integration — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace UUID-based license tokens with Ed25519-signed tokens minted by `cameleer-license-minter`, with full vendor UI for configurable minting, distribution, and verification.
|
||||
|
||||
**Architecture:** The SaaS platform embeds `cameleer-license-minter` as a Maven dependency and calls `LicenseMinter.mint()` with an Ed25519 private key stored in the DB. Signed tokens are pushed to tenant servers via env vars and REST API. The vendor UI provides tier presets with per-limit customization, copy/email distribution as env-var bundles, and a token verification tool.
|
||||
|
||||
**Tech Stack:** Spring Boot 3.4, JPA/Flyway/PostgreSQL, Ed25519 (JCE), `cameleer-license-minter` + `cameleer-server-core` (LicenseInfo, LicenseValidator), React 19, @cameleer/design-system, TanStack Query.
|
||||
|
||||
**Decisions:**
|
||||
- Tiers renamed: LOW→STARTER, MID→TEAM, HIGH→BUSINESS, BUSINESS→ENTERPRISE
|
||||
- Tiers are presets only — vendor can customize any limit (becomes "Custom" in UI)
|
||||
- Private key stored in DB (signing_keys table)
|
||||
- Features concept dropped — server enforces caps, not feature flags
|
||||
- Standalone distribution: license bundle = token + public key + tenant ID as env vars
|
||||
- Verify tool: paste token → decode + validate signature → show envelope + state
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Backend Foundation
|
||||
|
||||
### Task 1: Maven dependency + Flyway migration
|
||||
|
||||
**Files:**
|
||||
- Modify: `pom.xml`
|
||||
- Create: `src/main/resources/db/migration/V002__license_minter.sql`
|
||||
|
||||
- [ ] **Step 1: Add minter dependency to pom.xml**
|
||||
|
||||
Add inside `<dependencies>`:
|
||||
```xml
|
||||
<!-- License Minter (Ed25519 signing) -->
|
||||
<dependency>
|
||||
<groupId>com.cameleer</groupId>
|
||||
<artifactId>cameleer-license-minter</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
This transitively brings `cameleer-server-core` (for `LicenseInfo`, `LicenseValidator`).
|
||||
|
||||
- [ ] **Step 2: Create Flyway V002 migration**
|
||||
|
||||
```sql
|
||||
-- V002: License minter integration
|
||||
-- Signing keys for Ed25519 license minting
|
||||
CREATE TABLE signing_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
public_key_b64 TEXT NOT NULL,
|
||||
private_key_b64 TEXT NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Rename tiers: LOW→STARTER, MID→TEAM, HIGH→BUSINESS, BUSINESS→ENTERPRISE
|
||||
UPDATE tenants SET tier = 'STARTER' WHERE tier = 'LOW';
|
||||
UPDATE tenants SET tier = 'TEAM' WHERE tier = 'MID';
|
||||
UPDATE tenants SET tier = 'BUSINESS' WHERE tier = 'HIGH';
|
||||
UPDATE tenants SET tier = 'ENTERPRISE' WHERE tier = 'BUSINESS';
|
||||
-- Fix double-rename: HIGH→BUSINESS rows that got caught by BUSINESS→ENTERPRISE
|
||||
-- Use a single pass via CASE to avoid this:
|
||||
-- Actually, redo with CASE statement in a single UPDATE:
|
||||
|
||||
-- (Replace the 4 UPDATEs above with this single safe statement)
|
||||
UPDATE tenants SET tier = CASE tier
|
||||
WHEN 'LOW' THEN 'STARTER'
|
||||
WHEN 'MID' THEN 'TEAM'
|
||||
WHEN 'HIGH' THEN 'BUSINESS'
|
||||
WHEN 'BUSINESS' THEN 'ENTERPRISE'
|
||||
ELSE tier
|
||||
END WHERE tier IN ('LOW', 'MID', 'HIGH', 'BUSINESS');
|
||||
|
||||
-- Same for licenses table
|
||||
UPDATE licenses SET tier = CASE tier
|
||||
WHEN 'LOW' THEN 'STARTER'
|
||||
WHEN 'MID' THEN 'TEAM'
|
||||
WHEN 'HIGH' THEN 'BUSINESS'
|
||||
WHEN 'BUSINESS' THEN 'ENTERPRISE'
|
||||
ELSE tier
|
||||
END WHERE tier IN ('LOW', 'MID', 'HIGH', 'BUSINESS');
|
||||
|
||||
-- Add new license columns
|
||||
ALTER TABLE licenses ADD COLUMN label VARCHAR(255);
|
||||
ALTER TABLE licenses ADD COLUMN grace_period_days INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- Drop features column (server enforces caps, not feature flags)
|
||||
ALTER TABLE licenses DROP COLUMN features;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify build compiles**
|
||||
|
||||
Run: `mvn compile -q` (just compile, no tests yet — tests will break until Tier enum is updated)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```
|
||||
feat: add cameleer-license-minter dependency and V002 migration
|
||||
|
||||
Adds Ed25519 license minting library, signing_keys table,
|
||||
renames tiers (LOW→STARTER, MID→TEAM, HIGH→BUSINESS, BUSINESS→ENTERPRISE),
|
||||
adds label + grace_period_days to licenses, drops features column.
|
||||
```
|
||||
|
||||
### Task 2: Tier enum rename + LicenseDefaults rewrite
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/tenant/Tier.java`
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/tenant/TenantEntity.java`
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java`
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/LicenseDefaults.java`
|
||||
|
||||
- [ ] **Step 1: Update Tier enum**
|
||||
|
||||
```java
|
||||
package net.siegeln.cameleer.saas.tenant;
|
||||
|
||||
public enum Tier {
|
||||
STARTER, TEAM, BUSINESS, ENTERPRISE
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update TenantEntity default**
|
||||
|
||||
Change `private Tier tier = Tier.LOW;` to `private Tier tier = Tier.STARTER;`
|
||||
|
||||
- [ ] **Step 3: Update TenantService fallback**
|
||||
|
||||
Change `Tier.valueOf(request.tier()) : Tier.LOW` to `Tier.valueOf(request.tier()) : Tier.STARTER`
|
||||
|
||||
- [ ] **Step 4: Rewrite LicenseDefaults**
|
||||
|
||||
Replace entire file with 13-key limits per tier matching the handoff cap matrix. Drop `featuresForTier()`. Only `limitsForTier()`.
|
||||
|
||||
```java
|
||||
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 final int DEFAULT_GRACE_PERIOD_DAYS = 14;
|
||||
public static final int DEFAULT_LICENSE_DAYS = 365;
|
||||
|
||||
public static Map<String, Integer> limitsForTier(Tier tier) {
|
||||
return switch (tier) {
|
||||
case STARTER -> Map.ofEntries(
|
||||
Map.entry("max_environments", 2),
|
||||
Map.entry("max_apps", 10),
|
||||
Map.entry("max_agents", 20),
|
||||
Map.entry("max_users", 5),
|
||||
Map.entry("max_outbound_connections", 5),
|
||||
Map.entry("max_alert_rules", 10),
|
||||
Map.entry("max_total_cpu_millis", 8000),
|
||||
Map.entry("max_total_memory_mb", 8192),
|
||||
Map.entry("max_total_replicas", 25),
|
||||
Map.entry("max_execution_retention_days", 7),
|
||||
Map.entry("max_log_retention_days", 7),
|
||||
Map.entry("max_metric_retention_days", 7),
|
||||
Map.entry("max_jar_retention_count", 5)
|
||||
);
|
||||
case TEAM -> Map.ofEntries(
|
||||
Map.entry("max_environments", 5),
|
||||
Map.entry("max_apps", 50),
|
||||
Map.entry("max_agents", 100),
|
||||
Map.entry("max_users", 25),
|
||||
Map.entry("max_outbound_connections", 25),
|
||||
Map.entry("max_alert_rules", 50),
|
||||
Map.entry("max_total_cpu_millis", 32000),
|
||||
Map.entry("max_total_memory_mb", 32768),
|
||||
Map.entry("max_total_replicas", 100),
|
||||
Map.entry("max_execution_retention_days", 30),
|
||||
Map.entry("max_log_retention_days", 30),
|
||||
Map.entry("max_metric_retention_days", 30),
|
||||
Map.entry("max_jar_retention_count", 10)
|
||||
);
|
||||
case BUSINESS -> Map.ofEntries(
|
||||
Map.entry("max_environments", 10),
|
||||
Map.entry("max_apps", 200),
|
||||
Map.entry("max_agents", 500),
|
||||
Map.entry("max_users", 100),
|
||||
Map.entry("max_outbound_connections", 100),
|
||||
Map.entry("max_alert_rules", 200),
|
||||
Map.entry("max_total_cpu_millis", 128000),
|
||||
Map.entry("max_total_memory_mb", 131072),
|
||||
Map.entry("max_total_replicas", 500),
|
||||
Map.entry("max_execution_retention_days", 90),
|
||||
Map.entry("max_log_retention_days", 90),
|
||||
Map.entry("max_metric_retention_days", 90),
|
||||
Map.entry("max_jar_retention_count", 25)
|
||||
);
|
||||
case ENTERPRISE -> Map.ofEntries(
|
||||
Map.entry("max_environments", 50),
|
||||
Map.entry("max_apps", 1000),
|
||||
Map.entry("max_agents", 5000),
|
||||
Map.entry("max_users", 1000),
|
||||
Map.entry("max_outbound_connections", 500),
|
||||
Map.entry("max_alert_rules", 1000),
|
||||
Map.entry("max_total_cpu_millis", 512000),
|
||||
Map.entry("max_total_memory_mb", 524288),
|
||||
Map.entry("max_total_replicas", 2000),
|
||||
Map.entry("max_execution_retention_days", 365),
|
||||
Map.entry("max_log_retention_days", 180),
|
||||
Map.entry("max_metric_retention_days", 180),
|
||||
Map.entry("max_jar_retention_count", 50)
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
refactor: rename tiers and rewrite LicenseDefaults to 13-key cap matrix
|
||||
```
|
||||
|
||||
### Task 3: SigningKeyService + SigningKeyEntity
|
||||
|
||||
**Files:**
|
||||
- Create: `src/main/java/net/siegeln/cameleer/saas/license/SigningKeyEntity.java`
|
||||
- Create: `src/main/java/net/siegeln/cameleer/saas/license/SigningKeyRepository.java`
|
||||
- Create: `src/main/java/net/siegeln/cameleer/saas/license/SigningKeyService.java`
|
||||
|
||||
- [ ] **Step 1: Create SigningKeyEntity**
|
||||
|
||||
JPA entity for the `signing_keys` table: id (UUID), publicKeyB64 (text), privateKeyB64 (text), active (boolean), createdAt (Instant).
|
||||
|
||||
- [ ] **Step 2: Create SigningKeyRepository**
|
||||
|
||||
JpaRepository with `Optional<SigningKeyEntity> findByActiveTrue()`.
|
||||
|
||||
- [ ] **Step 3: Create SigningKeyService**
|
||||
|
||||
Methods:
|
||||
- `getOrCreateActiveKey()` → returns the active key, generating a new Ed25519 keypair on first call
|
||||
- `getPublicKeyBase64()` → convenience for the active key's public key
|
||||
- `getPrivateKey()` → reconstructs `PrivateKey` from stored base64
|
||||
|
||||
Key generation:
|
||||
```java
|
||||
KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
|
||||
String pubB64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
|
||||
String privB64 = Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded());
|
||||
```
|
||||
|
||||
Private key reconstruction:
|
||||
```java
|
||||
byte[] keyBytes = Base64.getDecoder().decode(entity.getPrivateKeyB64());
|
||||
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
|
||||
return KeyFactory.getInstance("Ed25519").generatePrivate(spec);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```
|
||||
feat: add SigningKeyService for Ed25519 keypair management
|
||||
```
|
||||
|
||||
### Task 4: Rewrite LicenseService + LicenseEntity
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/LicenseEntity.java`
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/LicenseService.java`
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/dto/LicenseResponse.java`
|
||||
|
||||
- [ ] **Step 1: Update LicenseEntity**
|
||||
|
||||
- Remove `features` field + getter/setter
|
||||
- Add `label` (String) field + getter/setter
|
||||
- Add `gracePeriodDays` (int) field + getter/setter
|
||||
|
||||
- [ ] **Step 2: Rewrite LicenseService**
|
||||
|
||||
- Add `SigningKeyService` dependency
|
||||
- Rewrite `generateLicense(TenantEntity, Map<String,Integer> limits, Instant expiresAt, int gracePeriodDays, String label, UUID actorId)`:
|
||||
- Build `LicenseInfo(UUID.randomUUID(), tenant.getSlug(), label, limits, Instant.now(), expiresAt, gracePeriodDays)`
|
||||
- Call `LicenseMinter.mint(info, signingKeyService.getPrivateKey())`
|
||||
- Store signed token in entity
|
||||
- Add convenience overload `generateLicense(TenantEntity, Duration, UUID actorId)` that uses tier presets
|
||||
- Remove `verifyLicenseToken()` (server validates cryptographically)
|
||||
- Add `verifyToken(String token)` that uses `LicenseValidator`
|
||||
|
||||
- [ ] **Step 3: Update LicenseResponse DTO**
|
||||
|
||||
Replace `features` with `label` and `gracePeriodDays`. Add `publicKeyB64` for bundle distribution.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```
|
||||
feat: rewrite LicenseService to mint Ed25519-signed tokens
|
||||
```
|
||||
|
||||
### Task 5: Update controllers + portal service
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/LicenseController.java`
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java`
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java`
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java`
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java`
|
||||
|
||||
- [ ] **Step 1: Update VendorTenantController**
|
||||
|
||||
- `POST /{id}/license` now takes a request body with limits, expiresAt, gracePeriodDays, label
|
||||
- Add `GET /license-presets` endpoint returning tier presets
|
||||
- Add `POST /license/verify` endpoint
|
||||
- Add `GET /signing-key/public` endpoint
|
||||
|
||||
- [ ] **Step 2: Update VendorTenantService**
|
||||
|
||||
- `renewLicense()` updated to accept customizable parameters
|
||||
- Add `mintLicense()` method with full limit configuration
|
||||
- Add `verifyToken()` delegation
|
||||
|
||||
- [ ] **Step 3: Update VendorTenantController response types**
|
||||
|
||||
- `VendorTenantSummary` — fix `agentLimit` to use `max_agents` key
|
||||
- `VendorTenantDetail` — license field uses updated LicenseResponse
|
||||
|
||||
- [ ] **Step 4: Update TenantPortalService**
|
||||
|
||||
- `DashboardData` — drop features, keep limits
|
||||
- `LicenseData` — drop features, add label + gracePeriodDays
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
feat: update vendor/portal APIs for Ed25519 license minting
|
||||
```
|
||||
|
||||
### Task 6: Fix tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/test/java/net/siegeln/cameleer/saas/license/LicenseServiceTest.java`
|
||||
- Modify: `src/test/java/net/siegeln/cameleer/saas/license/LicenseControllerTest.java`
|
||||
- Modify: `src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantServiceTest.java`
|
||||
- Modify: `src/test/java/net/siegeln/cameleer/saas/tenant/TenantServiceTest.java`
|
||||
- Modify: `src/test/java/net/siegeln/cameleer/saas/portal/TenantPortalServiceTest.java`
|
||||
- Modify: `src/test/java/net/siegeln/cameleer/saas/portal/TenantPortalControllerTest.java`
|
||||
|
||||
- [ ] **Step 1: Update all Tier.LOW→STARTER, Tier.MID→TEAM, Tier.HIGH→BUSINESS, Tier.BUSINESS→ENTERPRISE**
|
||||
|
||||
- [ ] **Step 2: Update LicenseServiceTest**
|
||||
|
||||
- `generateLicense_producesUuidToken` → rename to `generateLicense_producesSignedToken`, assert token contains `.` separator
|
||||
- Remove feature-related assertions
|
||||
- Mock `SigningKeyService` to return a test keypair
|
||||
- Remove `verifyLicenseToken` tests
|
||||
|
||||
- [ ] **Step 3: Update LicenseControllerTest**
|
||||
|
||||
- Remove feature assertions (`features.correlation`)
|
||||
- Update tier values in assertions
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `mvn test -q`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
test: update tests for Ed25519 license minting and tier rename
|
||||
```
|
||||
|
||||
## Phase 2: Provisioning Integration
|
||||
|
||||
### Task 7: Push public key to tenant containers
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java`
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java`
|
||||
|
||||
- [ ] **Step 1: Inject SigningKeyService into DockerTenantProvisioner**
|
||||
|
||||
Add `SigningKeyService` as a constructor dependency.
|
||||
|
||||
- [ ] **Step 2: Add CAMELEER_SERVER_LICENSE_PUBLICKEY env var**
|
||||
|
||||
In `createServerContainer()`, after the existing env vars, add:
|
||||
```java
|
||||
"CAMELEER_SERVER_LICENSE_PUBLICKEY=" + signingKeyService.getPublicKeyBase64()
|
||||
```
|
||||
|
||||
`CAMELEER_SERVER_TENANT_ID` is already set to slug (line 218).
|
||||
`CAMELEER_SERVER_LICENSE_TOKEN` is already set (line 225).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat: push Ed25519 public key to tenant server containers
|
||||
```
|
||||
|
||||
## Phase 3: Vendor API — Configurable Minting
|
||||
|
||||
### Task 8: Vendor license endpoints
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java`
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java`
|
||||
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/MintLicenseRequest.java`
|
||||
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/VerifyLicenseRequest.java`
|
||||
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/VerifyLicenseResponse.java`
|
||||
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/LicensePreset.java`
|
||||
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/LicenseBundleResponse.java`
|
||||
|
||||
- [ ] **Step 1: Create DTOs**
|
||||
|
||||
`MintLicenseRequest`: tier (optional String), limits (Map<String,Integer>), expiresAt (Instant), gracePeriodDays (Integer), label (String), pushToServer (boolean)
|
||||
|
||||
`VerifyLicenseRequest`: token (String)
|
||||
|
||||
`VerifyLicenseResponse`: valid (boolean), state (String), envelope fields (tenantId, label, limits, issuedAt, expiresAt, gracePeriodDays), error (String)
|
||||
|
||||
`LicensePreset`: tier (String), limits (Map<String,Integer>)
|
||||
|
||||
`LicenseBundleResponse`: extends LicenseResponse + adds publicKeyB64, tenantSlug (for the env-var bundle)
|
||||
|
||||
- [ ] **Step 2: Update VendorTenantService**
|
||||
|
||||
Add `mintLicense(UUID tenantId, MintLicenseRequest request, UUID actorId)`:
|
||||
- Resolves limits from request (or tier preset)
|
||||
- Calls `licenseService.generateLicense()` with full params
|
||||
- Optionally pushes to server
|
||||
- Returns the license + public key + slug for the bundle
|
||||
|
||||
Add `verifyToken(String token)`:
|
||||
- Uses LicenseValidator from server-core
|
||||
|
||||
- [ ] **Step 3: Update VendorTenantController**
|
||||
|
||||
- `POST /{id}/license` — takes MintLicenseRequest body, returns LicenseBundleResponse
|
||||
- `GET /license-presets` — returns list of LicensePreset
|
||||
- `POST /license/verify` — takes VerifyLicenseRequest, returns VerifyLicenseResponse
|
||||
- `GET /signing-key/public` — returns `{"publicKey": "<base64>"}`
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```
|
||||
feat: add vendor license minting, presets, and verify endpoints
|
||||
```
|
||||
|
||||
## Phase 4: Vendor UI — License Minting
|
||||
|
||||
### Task 9: Update frontend types + hooks
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui/src/types/api.ts`
|
||||
- Modify: `ui/src/api/vendor-hooks.ts`
|
||||
|
||||
- [ ] **Step 1: Update types**
|
||||
|
||||
- `LicenseResponse` — remove `features`, add `label`, `gracePeriodDays`, `publicKeyB64`, `tenantSlug`
|
||||
- Add `MintLicenseRequest`, `VerifyLicenseRequest`, `VerifyLicenseResponse`, `LicensePreset`, `LicenseBundleResponse`
|
||||
- `DashboardData` — remove `features`
|
||||
- `TenantLicenseData` — remove `features`, add `label`, `gracePeriodDays`
|
||||
|
||||
- [ ] **Step 2: Update hooks**
|
||||
|
||||
- `useRenewLicense()` → replace with `useMintLicense(tenantId)` that takes MintLicenseRequest body
|
||||
- Add `useLicensePresets()`
|
||||
- Add `useVerifyLicense()`
|
||||
- Add `usePublicKey()`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat(ui): update types and hooks for Ed25519 license minting
|
||||
```
|
||||
|
||||
### Task 10: License minting form on TenantDetailPage
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui/src/pages/vendor/TenantDetailPage.tsx`
|
||||
|
||||
- [ ] **Step 1: Replace License card**
|
||||
|
||||
Replace the simple "Renew License" button with a minting form:
|
||||
- Tier preset dropdown (STARTER/TEAM/BUSINESS/ENTERPRISE) that pre-fills limits
|
||||
- All 13 limits editable in a grid
|
||||
- Expiry date picker, grace period input, label input
|
||||
- "Custom" indicator when limits diverge from preset
|
||||
- Actions: "Mint & Push to Server" (default), "Mint & Copy Bundle", "Mint & Email Bundle"
|
||||
|
||||
- [ ] **Step 2: License bundle display**
|
||||
|
||||
After minting, show a dialog/card with the full env-var bundle:
|
||||
```
|
||||
CAMELEER_SERVER_TENANT_ID=<slug>
|
||||
CAMELEER_SERVER_LICENSE_PUBLICKEY=<public_key>
|
||||
CAMELEER_SERVER_LICENSE_TOKEN=<token>
|
||||
```
|
||||
With a "Copy Bundle" button.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat(ui): add license minting form with tier presets and bundle distribution
|
||||
```
|
||||
|
||||
### Task 11: License verify tool + public key viewer
|
||||
|
||||
**Files:**
|
||||
- Create: `ui/src/pages/vendor/LicenseVerifyPage.tsx`
|
||||
- Modify: `ui/src/router.tsx` (add route)
|
||||
- Modify: `ui/src/Layout.tsx` (add nav item)
|
||||
|
||||
- [ ] **Step 1: Create LicenseVerifyPage**
|
||||
|
||||
- Textarea to paste a token
|
||||
- "Verify" button
|
||||
- Results: valid/invalid badge, decoded envelope (tenantId, label, limits, expiry, grace period)
|
||||
- State badge (ACTIVE/GRACE/EXPIRED/INVALID)
|
||||
- Public key display section with copy button
|
||||
|
||||
- [ ] **Step 2: Add route and navigation**
|
||||
|
||||
Route: `/vendor/license-verify`
|
||||
Nav: "License Tools" section in vendor sidebar
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat(ui): add license verify tool and public key viewer
|
||||
```
|
||||
|
||||
### Task 12: Update tier color utility
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui/src/utils/tier.ts`
|
||||
|
||||
- [ ] **Step 1: Update tierColor**
|
||||
|
||||
```typescript
|
||||
export function tierColor(tier: string): 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto' {
|
||||
switch (tier?.toUpperCase()) {
|
||||
case 'ENTERPRISE': return 'success';
|
||||
case 'BUSINESS': return 'primary';
|
||||
case 'TEAM': return 'running';
|
||||
case 'STARTER': return 'warning';
|
||||
default: return 'auto';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```
|
||||
fix(ui): update tier color mapping for renamed tiers
|
||||
```
|
||||
|
||||
## Phase 5: Tenant UI Updates
|
||||
|
||||
### Task 13: Update TenantLicensePage
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui/src/pages/tenant/TenantLicensePage.tsx`
|
||||
|
||||
- [ ] **Step 1: Remove features card, update limits card**
|
||||
|
||||
- Drop the "Features" card entirely
|
||||
- Update "Limits & Usage" card to show all 13 limit keys with proper labels
|
||||
- Show grace period and label if present
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```
|
||||
feat(ui): update tenant license page for Ed25519 model
|
||||
```
|
||||
|
||||
### Task 14: Update TenantDashboardPage
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui/src/pages/tenant/TenantDashboardPage.tsx`
|
||||
|
||||
- [ ] **Step 1: Remove features references**
|
||||
|
||||
Drop any `features` display. Keep limits display.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```
|
||||
fix(ui): remove features from tenant dashboard
|
||||
```
|
||||
|
||||
### Task 15: Update CreateTenantPage
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui/src/pages/vendor/CreateTenantPage.tsx`
|
||||
|
||||
- [ ] **Step 1: Update tier options**
|
||||
|
||||
Change tier dropdown options from LOW/MID/HIGH/BUSINESS to STARTER/TEAM/BUSINESS/ENTERPRISE.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```
|
||||
fix(ui): update tier options in create tenant form
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
After all tasks:
|
||||
- [ ] `mvn test` passes
|
||||
- [ ] `cd ui && npm run build` succeeds
|
||||
- [ ] Docker compose boots (if available)
|
||||
- [ ] Verify a tenant can be created with STARTER tier
|
||||
- [ ] Verify license is minted with Ed25519 signature (token contains `.`)
|
||||
- [ ] Verify CAMELEER_SERVER_LICENSE_PUBLICKEY appears in container env
|
||||